Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 4507a999fc69b8d40f6250a515ac520beb03dec8


Parents : 6a61441
Author : Sudo-Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-01-04T12:41:34-06:00

feat(frontend): big updates (too many)

Changes
Diff

diff --git a/meshchatx/src/frontend/components/LanguageSelector.vue b/meshchatx/src/frontend/components/LanguageSelector.vue
index 44b87e93..7ad6d8de 100644
--- a/meshchatx/src/frontend/components/LanguageSelector.vue
+++ b/meshchatx/src/frontend/components/LanguageSelector.vue
@@ -4,34 +4,37 @@
type="button"
class="relative rounded-full p-1.5 sm:p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
:title="$t('app.language')"
- @click="toggleDropdown"
+ @click.stop="toggleDropdown"
>
<MaterialDesignIcon icon-name="translate" class="w-5 h-5 sm:w-6 sm:h-6" />
</button>
- <div
- v-if="isDropdownOpen"
- v-click-outside="closeDropdown"
- class="absolute right-0 mt-2 w-48 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-2xl shadow-xl z-[9999] overflow-hidden"
- >
- <div class="p-2">
- <button
- v-for="lang in languages"
- :key="lang.code"
- type="button"
- class="w-full px-4 py-2 text-left rounded-lg hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors flex items-center justify-between"
- :class="{
- 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400':
- currentLanguage === lang.code,
- 'text-gray-900 dark:text-zinc-100': currentLanguage !== lang.code,
- }"
- @click="selectLanguage(lang.code)"
- >
- <span class="font-medium">{{ lang.name }}</span>
- <MaterialDesignIcon v-if="currentLanguage === lang.code" icon-name="check" class="w-5 h-5" />
- </button>
+ <Teleport to="body">
+ <div
+ v-if="isDropdownOpen"
+ v-click-outside="closeDropdown"
+ class="fixed w-48 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-2xl shadow-xl z-[9999] overflow-hidden"
+ :style="dropdownStyle"
+ >
+ <div class="p-2">
+ <button
+ v-for="lang in languages"
+ :key="lang.code"
+ type="button"
+ class="w-full px-4 py-2 text-left rounded-lg hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors flex items-center justify-between"
+ :class="{
+ 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400':
+ currentLanguage === lang.code,
+ 'text-gray-900 dark:text-zinc-100': currentLanguage !== lang.code,
+ }"
+ @click="selectLanguage(lang.code)"
+ >
+ <span class="font-medium">{{ lang.name }}</span>
+ <MaterialDesignIcon v-if="currentLanguage === lang.code" icon-name="check" class="w-5 h-5" />
+ </button>
+ </div>
</div>
- </div>
+ </Teleport>
</div>
</template>
@@ -62,6 +65,7 @@ export default {
data() {
return {
isDropdownOpen: false,
+ dropdownPosition: { top: 0, left: 0 },
languages: [
{ code: "en", name: "English" },
{ code: "de", name: "Deutsch" },
@@ -73,10 +77,27 @@ export default {
currentLanguage() {
return this.$i18n.locale;
},
+ dropdownStyle() {
+ return {
+ top: `${this.dropdownPosition.top}px`,
+ left: `${this.dropdownPosition.left}px`,
+ };
+ },
},
methods: {
- toggleDropdown() {
+ toggleDropdown(event) {
this.isDropdownOpen = !this.isDropdownOpen;
+ if (this.isDropdownOpen) {
+ this.updateDropdownPosition(event);
+ }
+ },
+ updateDropdownPosition(event) {
+ const button = event.currentTarget;
+ const rect = button.getBoundingClientRect();
+ this.dropdownPosition = {
+ top: rect.bottom + 8,
+ left: Math.max(8, rect.right - 192), // 192px is w-48
+ };
},
closeDropdown() {
this.isDropdownOpen = false;

diff --git a/meshchatx/src/frontend/components/LxmfUserIcon.vue b/meshchatx/src/frontend/components/LxmfUserIcon.vue
index b3ea2a0c..d054c887 100644
--- a/meshchatx/src/frontend/components/LxmfUserIcon.vue
+++ b/meshchatx/src/frontend/components/LxmfUserIcon.vue
@@ -16,10 +16,10 @@
</div>
<div
v-else
- class="bg-gray-200 dark:bg-zinc-700 text-gray-500 dark:text-gray-400 p-[15%] rounded-full shrink-0 flex items-center justify-center"
+ class="bg-gray-100 dark:bg-zinc-800 text-gray-400 dark:text-zinc-500 p-[15%] rounded-full shrink-0 flex items-center justify-center border border-gray-200 dark:border-zinc-700"
:class="iconClass || 'size-6'"
>
- <MaterialDesignIcon icon-name="account-outline" class="w-full h-full" />
+ <MaterialDesignIcon icon-name="account" class="w-full h-full" />
</div>
</template>

diff --git a/meshchatx/src/frontend/components/NotificationBell.vue b/meshchatx/src/frontend/components/NotificationBell.vue
index 6871ce47..64170e89 100644
--- a/meshchatx/src/frontend/components/NotificationBell.vue
+++ b/meshchatx/src/frontend/components/NotificationBell.vue
@@ -3,7 +3,7 @@
<button
type="button"
class="relative rounded-full p-1.5 sm:p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
- @click="toggleDropdown"
+ @click.stop="toggleDropdown"
>
<MaterialDesignIcon icon-name="bell" class="w-5 h-5 sm:w-6 sm:h-6" />
<span
@@ -14,96 +14,103 @@
</span>
</button>
- <div
- v-if="isDropdownOpen"
- v-click-outside="closeDropdown"
- class="absolute right-0 mt-2 w-80 sm:w-96 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-2xl shadow-xl z-[9999] max-h-[500px] overflow-hidden flex flex-col"
- >
- <div class="p-4 border-b border-gray-200 dark:border-zinc-800">
- <div class="flex items-center justify-between">
- <h3 class="text-lg font-semibold text-gray-900 dark:text-white">Notifications</h3>
- <button
- type="button"
- class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
- @click="closeDropdown"
- >
- <MaterialDesignIcon icon-name="close" class="w-5 h-5" />
- </button>
+ <Teleport to="body">
+ <div
+ v-if="isDropdownOpen"
+ v-click-outside="closeDropdown"
+ class="fixed w-80 sm:w-96 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-2xl shadow-xl z-[9999] max-h-[500px] overflow-hidden flex flex-col"
+ :style="dropdownStyle"
+ >
+ <div class="p-4 border-b border-gray-200 dark:border-zinc-800">
+ <div class="flex items-center justify-between">
+ <h3 class="text-lg font-semibold text-gray-900 dark:text-white">Notifications</h3>
+ <button
+ type="button"
+ class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
+ @click="closeDropdown"
+ >
+ <MaterialDesignIcon icon-name="close" class="w-5 h-5" />
+ </button>
+ </div>
</div>
- </div>
- <div class="overflow-y-auto flex-1">
- <div v-if="isLoading" class="p-8 text-center">
- <div class="inline-block animate-spin text-gray-400">
- <MaterialDesignIcon icon-name="refresh" class="w-6 h-6" />
+ <div class="overflow-y-auto flex-1">
+ <div v-if="isLoading" class="p-8 text-center">
+ <div class="inline-block animate-spin text-gray-400">
+ <MaterialDesignIcon icon-name="refresh" class="w-6 h-6" />
+ </div>
+ <div class="mt-2 text-sm text-gray-500 dark:text-gray-400">Loading notifications...</div>
</div>
- <div class="mt-2 text-sm text-gray-500 dark:text-gray-400">Loading notifications...</div>
- </div>
- <div v-else-if="notifications.length === 0" class="p-8 text-center">
- <MaterialDesignIcon
- icon-name="bell-off"
- class="w-12 h-12 mx-auto text-gray-400 dark:text-gray-500"
- />
- <div class="mt-2 text-sm text-gray-500 dark:text-gray-400">No new notifications</div>
- </div>
+ <div v-else-if="notifications.length === 0" class="p-8 text-center">
+ <MaterialDesignIcon
+ icon-name="bell-off"
+ class="w-12 h-12 mx-auto text-gray-400 dark:text-gray-500"
+ />
+ <div class="mt-2 text-sm text-gray-500 dark:text-gray-400">No new notifications</div>
+ </div>
- <div v-else class="divide-y divide-gray-200 dark:divide-zinc-800">
- <button
- v-for="notification in notifications"
- :key="notification.destination_hash"
- type="button"
- class="w-full p-4 hover:bg-gray-50 dark:hover:bg-zinc-800 transition-colors text-left"
- @click="onNotificationClick(notification)"
- >
- <div class="flex gap-3">
- <div class="flex-shrink-0">
- <div
- v-if="notification.lxmf_user_icon"
- class="p-2 rounded-lg"
- :style="{
- color: notification.lxmf_user_icon.foreground_colour,
- 'background-color': notification.lxmf_user_icon.background_colour,
- }"
- >
- <MaterialDesignIcon
- :icon-name="notification.lxmf_user_icon.icon_name"
- class="w-6 h-6"
- />
- </div>
- <div
- v-else
- class="bg-gray-200 dark:bg-zinc-700 text-gray-500 dark:text-gray-400 p-2 rounded-lg"
- >
- <MaterialDesignIcon icon-name="account-outline" class="w-6 h-6" />
- </div>
- </div>
- <div class="flex-1 min-w-0">
- <div class="flex items-start justify-between gap-2 mb-1">
+ <div v-else class="divide-y divide-gray-200 dark:divide-zinc-800">
+ <button
+ v-for="notification in notifications"
+ :key="notification.destination_hash"
+ type="button"
+ class="w-full p-4 hover:bg-gray-50 dark:hover:bg-zinc-800 transition-colors text-left"
+ @click="onNotificationClick(notification)"
+ >
+ <div class="flex gap-3">
+ <div class="flex-shrink-0">
<div
- class="font-semibold text-gray-900 dark:text-white truncate"
- :title="notification.custom_display_name ?? notification.display_name"
+ v-if="notification.lxmf_user_icon"
+ class="p-2 rounded-lg"
+ :style="{
+ color: notification.lxmf_user_icon.foreground_colour,
+ 'background-color': notification.lxmf_user_icon.background_colour,
+ }"
>
- {{ notification.custom_display_name ?? notification.display_name }}
+ <MaterialDesignIcon
+ :icon-name="notification.lxmf_user_icon.icon_name"
+ class="w-6 h-6"
+ />
</div>
<div
- class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap flex-shrink-0"
+ v-else
+ class="bg-gray-200 dark:bg-zinc-700 text-gray-500 dark:text-gray-400 p-2 rounded-lg"
>
- {{ formatTimeAgo(notification.updated_at) }}
+ <MaterialDesignIcon icon-name="account-outline" class="w-6 h-6" />
</div>
</div>
- <div
- class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2"
- :title="notification.latest_message_preview ?? notification.content ?? 'No preview'"
- >
- {{ notification.latest_message_preview ?? notification.content ?? "No preview" }}
+ <div class="flex-1 min-w-0">
+ <div class="flex items-start justify-between gap-2 mb-1">
+ <div
+ class="font-semibold text-gray-900 dark:text-white truncate"
+ :title="notification.custom_display_name ?? notification.display_name"
+ >
+ {{ notification.custom_display_name ?? notification.display_name }}
+ </div>
+ <div
+ class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap flex-shrink-0"
+ >
+ {{ formatTimeAgo(notification.updated_at) }}
+ </div>
+ </div>
+ <div
+ class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2"
+ :title="
+ notification.latest_message_preview ?? notification.content ?? 'No preview'
+ "
+ >
+ {{
+ notification.latest_message_preview ?? notification.content ?? "No preview"
+ }}
+ </div>
</div>
</div>
- </div>
- </button>
+ </button>
+ </div>
</div>
</div>
- </div>
+ </Teleport>
</div>
</template>
@@ -139,9 +146,17 @@ export default {
notifications: [],
unreadCount: 0,
reloadInterval: null,
+ dropdownPosition: { top: 0, left: 0 },
};
},
- computed: {},
+ computed: {
+ dropdownStyle() {
+ return {
+ top: `${this.dropdownPosition.top}px`,
+ left: `${this.dropdownPosition.left}px`,
+ };
+ },
+ },
beforeUnmount() {
if (this.reloadInterval) {
clearInterval(this.reloadInterval);
@@ -158,13 +173,25 @@ export default {
}, 5000);
},
methods: {
- async toggleDropdown() {
+ async toggleDropdown(event) {
this.isDropdownOpen = !this.isDropdownOpen;
if (this.isDropdownOpen) {
+ this.updateDropdownPosition(event);
await this.loadNotifications();
await this.markNotificationsAsViewed();
}
},
+ updateDropdownPosition(event) {
+ const button = event.currentTarget;
+ const rect = button.getBoundingClientRect();
+ const isMobile = window.innerWidth < 640;
+ const dropdownWidth = isMobile ? 320 : 384; // 80 (320px) or 96 (384px)
+
+ this.dropdownPosition = {
+ top: rect.bottom + 8,
+ left: Math.max(16, rect.right - dropdownWidth),
+ };
+ },
closeDropdown() {
this.isDropdownOpen = false;
},

diff --git a/meshchatx/src/frontend/components/about/AboutPage.vue b/meshchatx/src/frontend/components/about/AboutPage.vue
index 0aa12e4e..81fe9257 100644
--- a/meshchatx/src/frontend/components/about/AboutPage.vue
+++ b/meshchatx/src/frontend/components/about/AboutPage.vue
@@ -27,36 +27,19 @@
</div>
<div class="flex-1 md:text-right flex flex-wrap justify-start md:justify-end gap-3">
- <button
- type="button"
- class="secondary-chip px-5 py-3 text-sm font-black transition-all hover:scale-105 active:scale-95"
- @click="showTutorial"
- >
+ <button type="button" class="secondary-chip" @click="showTutorial">
<v-icon icon="mdi-help-circle" size="20" class="mr-2"></v-icon>
{{ $t("app.tutorial_title") }}
</button>
- <button
- type="button"
- class="secondary-chip px-5 py-3 text-sm font-black transition-all hover:scale-105 active:scale-95"
- @click="showChangelog"
- >
+ <button type="button" class="secondary-chip" @click="showChangelog">
<v-icon icon="mdi-history" size="20" class="mr-2"></v-icon>
{{ $t("app.changelog_title") }}
</button>
- <button
- v-if="isElectron"
- type="button"
- class="primary-chip px-5 py-3 text-sm font-black transition-all hover:scale-105 active:scale-95"
- @click="relaunch"
- >
+ <button v-if="isElectron" type="button" class="primary-chip" @click="relaunch">
<v-icon icon="mdi-restart" size="20" class="mr-2"></v-icon>
{{ $t("common.restart_app") }}
</button>
- <button
- type="button"
- class="danger-chip px-5 py-3 text-sm font-black transition-all hover:scale-105 active:scale-95"
- @click="shutdown"
- >
+ <button type="button" class="danger-chip" @click="shutdown">
<v-icon icon="mdi-power" size="20" class="mr-2"></v-icon>
{{ $t("common.shutdown", "Shutdown") }}
</button>
@@ -92,619 +75,577 @@
}}
</div>
</div>
- <div class="w-px h-12 bg-gray-100 dark:bg-zinc-800"></div>
- <v-btn
- variant="tonal"
- color="blue"
- height="48"
- class="font-black px-6 rounded-xl"
- @click="showAdvanced = !showAdvanced"
- >
- <v-icon :icon="showAdvanced ? 'mdi-chevron-up' : 'mdi-xml'" start></v-icon>
- {{ showAdvanced ? "Hide Advanced" : "Advanced Mode" }}
- </v-btn>
</div>
</div>
</div>
- <transition name="fade-blur">
- <div v-if="showAdvanced" class="space-y-6">
- <!-- Advanced Tech Info -->
- <div v-if="appInfo" class="glass-card !p-6">
+ <div class="space-y-6">
+ <!-- Security & Integrity -->
+ <div v-if="appInfo" class="glass-card !p-6">
+ <div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mb-6">
<div
- class="text-xs font-black text-blue-500 uppercase tracking-[0.2em] mb-6 flex items-center gap-2"
+ class="text-xs font-black text-blue-500 uppercase tracking-[0.2em] flex items-center gap-2"
>
- <v-icon icon="mdi-server" size="14"></v-icon>
- Environment Information
+ <v-icon icon="mdi-shield-lock" size="14"></v-icon>
+ Security & Integrity
</div>
- <div class="grid gap-8 sm:grid-cols-2 lg:grid-cols-3 text-sm">
- <div>
- <div class="glass-label !text-[10px] mb-2 opacity-50">Reticulum Config</div>
- <div
- class="monospace-field !bg-zinc-50 dark:!bg-zinc-950 break-all text-[11px] !p-3 rounded-xl border border-zinc-100 dark:border-zinc-800"
- >
- {{ appInfo.reticulum_config_path }}
- </div>
- <v-btn
- v-if="isElectron"
- variant="tonal"
- size="x-small"
- class="mt-3 font-black px-3"
- @click="showReticulumConfigFile"
- >
- <v-icon icon="mdi-folder-open" start size="14"></v-icon> Reveal File
- </v-btn>
- </div>
- <div>
- <div class="glass-label !text-[10px] mb-2 opacity-50">Database Path</div>
- <div
- class="monospace-field !bg-zinc-50 dark:!bg-zinc-950 break-all text-[11px] !p-3 rounded-xl border border-zinc-100 dark:border-zinc-800"
- >
- {{ appInfo.database_path }}
- </div>
- <v-btn
- v-if="isElectron"
- variant="tonal"
- size="x-small"
- class="mt-3 font-black px-3"
- @click="showDatabaseFile"
- >
- <v-icon icon="mdi-database-search" start size="14"></v-icon> Reveal DB
- </v-btn>
- </div>
- <div
- class="flex flex-col justify-center space-y-3 bg-zinc-50 dark:bg-zinc-950 p-4 rounded-xl border border-zinc-100 dark:border-zinc-800"
+ <div v-if="appInfo.integrity_issues" class="flex flex-wrap gap-2">
+ <span
+ :class="statusPillClass(appInfo.integrity_issues.length === 0)"
+ class="font-black px-3 py-1 text-[11px]"
>
- <div
- v-if="config"
- class="space-y-3 mb-2 pb-3 border-b border-zinc-100 dark:border-zinc-800"
- >
- <div class="flex flex-col">
- <span class="text-[9px] font-black text-blue-500 uppercase tracking-wider"
- >Identity Hash</span
- >
- <span class="font-mono text-[10px] break-all opacity-70">{{
- config.identity_hash
- }}</span>
- </div>
- <div class="flex flex-col">
- <span class="text-[9px] font-black text-blue-500 uppercase tracking-wider"
- >LXMF Address</span
- >
- <span class="font-mono text-[10px] break-all opacity-70">{{
- config.lxmf_address_hash
- }}</span>
- </div>
- </div>
- <div class="flex items-center justify-between">
- <span class="text-[10px] font-black text-blue-500 uppercase tracking-wider"
- >Python</span
- >
- <span class="font-mono text-xs font-bold">v{{ appInfo.python_version }}</span>
- </div>
- <div class="flex items-center justify-between">
- <span class="text-[10px] font-black text-purple-500 uppercase tracking-wider"
- >LXMF</span
- >
- <span class="font-mono text-xs font-bold">v{{ appInfo.lxmf_version }}</span>
- </div>
- <div class="flex items-center justify-between">
- <span class="text-[10px] font-black text-indigo-500 uppercase tracking-wider"
- >RNS</span
- >
- <span class="font-mono text-xs font-bold">v{{ appInfo.rns_version }}</span>
- </div>
- </div>
+ <v-icon
+ :icon="
+ appInfo.integrity_issues.length === 0
+ ? 'mdi-shield-check'
+ : 'mdi-shield-alert'
+ "
+ size="14"
+ start
+ ></v-icon>
+ {{
+ appInfo.integrity_issues.length === 0
+ ? $t("about.secured")
+ : $t("about.tampering_detected")
+ }}
+ </span>
+ <button
+ v-if="appInfo.integrity_issues.length > 0"
+ type="button"
+ class="secondary-chip px-3 py-1 text-[11px] font-black"
+ @click="acknowledgeIntegrity"
+ >
+ <v-icon icon="mdi-check-circle" size="14" start></v-icon>
+ {{ $t("common.acknowledge_reset") }}
+ </button>
</div>
</div>
- <!-- Security & Integrity -->
- <div v-if="appInfo" class="glass-card !p-6">
- <div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mb-6">
+ <div
+ v-if="appInfo.integrity_issues && appInfo.integrity_issues.length > 0"
+ class="p-4 bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900 rounded-xl"
+ >
+ <div
+ class="text-xs font-black text-red-700 dark:text-red-400 mb-3 uppercase tracking-wider flex items-center gap-2"
+ >
+ <v-icon icon="mdi-alert-octagon" size="16"></v-icon>
+ Technical Issues Detected
+ </div>
+ <ul class="text-[11px] text-red-600 dark:text-red-300 space-y-2 list-none font-mono">
+ <li v-for="(issue, index) in appInfo.integrity_issues" :key="index" class="flex gap-2">
+ <span class="opacity-50">•</span>
+ <span>{{ issue }}</span>
+ </li>
+ </ul>
+ </div>
+ <div
+ v-else
+ class="text-sm text-gray-500 dark:text-zinc-500 flex items-center gap-3 bg-emerald-500/5 p-4 rounded-xl border border-emerald-500/10"
+ >
+ <v-icon icon="mdi-check-decagram" color="green" size="20"></v-icon>
+ <span class="font-bold tracking-tight">{{ $t("about.no_integrity_violations") }}</span>
+ </div>
+ </div>
+
+ <!-- Advanced Tech Info -->
+ <div v-if="appInfo" class="glass-card !p-6">
+ <div
+ class="text-xs font-black text-blue-500 uppercase tracking-[0.2em] mb-6 flex items-center gap-2"
+ >
+ <v-icon icon="mdi-server" size="14"></v-icon>
+ Environment Information
+ </div>
+ <div class="grid gap-8 sm:grid-cols-2 lg:grid-cols-3 text-sm">
+ <div>
+ <div class="glass-label !text-[10px] mb-2 opacity-50">Reticulum Config</div>
<div
- class="text-xs font-black text-blue-500 uppercase tracking-[0.2em] flex items-center gap-2"
+ class="monospace-field !bg-zinc-50 dark:!bg-zinc-950 break-all text-[11px] !p-3 rounded-xl border border-zinc-100 dark:border-zinc-800"
>
- <v-icon icon="mdi-shield-lock" size="14"></v-icon>
- Security & Integrity
+ {{ appInfo.reticulum_config_path }}
</div>
- <div v-if="appInfo.integrity_issues" class="flex flex-wrap gap-2">
- <span
- :class="statusPillClass(appInfo.integrity_issues.length === 0)"
- class="font-black px-3 py-1 text-[11px]"
- >
- <v-icon
- :icon="
- appInfo.integrity_issues.length === 0
- ? 'mdi-shield-check'
- : 'mdi-shield-alert'
- "
- size="14"
- start
- ></v-icon>
- {{
- appInfo.integrity_issues.length === 0
- ? $t("about.secured")
- : $t("about.tampering_detected")
- }}
- </span>
- <button
- v-if="appInfo.integrity_issues.length > 0"
- type="button"
- class="secondary-chip px-3 py-1 text-[11px] font-black"
- @click="acknowledgeIntegrity"
- >
- <v-icon icon="mdi-check-circle" size="14" start></v-icon>
- {{ $t("common.acknowledge_reset") }}
- </button>
+ <button
+ v-if="isElectron"
+ type="button"
+ class="secondary-chip mt-3 !px-3 !py-1 !text-[10px]"
+ @click="showReticulumConfigFile"
+ >
+ <v-icon icon="mdi-folder-open" start size="14"></v-icon> Reveal File
+ </button>
+ </div>
+ <div>
+ <div class="glass-label !text-[10px] mb-2 opacity-50">Database Path</div>
+ <div
+ class="monospace-field !bg-zinc-50 dark:!bg-zinc-950 break-all text-[11px] !p-3 rounded-xl border border-zinc-100 dark:border-zinc-800"
+ >
+ {{ appInfo.database_path }}
</div>
+ <button
+ v-if="isElectron"
+ type="button"
+ class="secondary-chip mt-3 !px-3 !py-1 !text-[10px]"
+ @click="showDatabaseFile"
+ >
+ <v-icon icon="mdi-database-search" start size="14"></v-icon> Reveal DB
+ </button>
</div>
-
<div
- v-if="appInfo.integrity_issues && appInfo.integrity_issues.length > 0"
- class="p-4 bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900 rounded-xl"
+ class="flex flex-col justify-center space-y-3 bg-zinc-50 dark:bg-zinc-950 p-4 rounded-xl border border-zinc-100 dark:border-zinc-800"
>
<div
- class="text-xs font-black text-red-700 dark:text-red-400 mb-3 uppercase tracking-wider flex items-center gap-2"
+ v-if="config"
+ class="space-y-3 mb-2 pb-3 border-b border-zinc-100 dark:border-zinc-800"
>
- <v-icon icon="mdi-alert-octagon" size="16"></v-icon>
- Technical Issues Detected
+ <div class="flex flex-col">
+ <span class="text-[9px] font-black text-blue-500 uppercase tracking-wider"
+ >Identity Hash</span
+ >
+ <span class="font-mono text-[10px] break-all opacity-70">{{
+ config.identity_hash
+ }}</span>
+ </div>
+ <div class="flex flex-col">
+ <span class="text-[9px] font-black text-blue-500 uppercase tracking-wider"
+ >LXMF Address</span
+ >
+ <span class="font-mono text-[10px] break-all opacity-70">{{
+ config.lxmf_address_hash
+ }}</span>
+ </div>
</div>
- <ul class="text-[11px] text-red-600 dark:text-red-300 space-y-2 list-none font-mono">
- <li
- v-for="(issue, index) in appInfo.integrity_issues"
- :key="index"
- class="flex gap-2"
+ <div class="flex items-center justify-between">
+ <span class="text-[10px] font-black text-blue-500 uppercase tracking-wider"
+ >Python</span
>
- <span class="opacity-50">•</span>
- <span>{{ issue }}</span>
- </li>
- </ul>
- </div>
- <div
- v-else
- class="text-sm text-gray-500 dark:text-zinc-500 flex items-center gap-3 bg-emerald-500/5 p-4 rounded-xl border border-emerald-500/10"
- >
- <v-icon icon="mdi-check-decagram" color="emerald" size="20"></v-icon>
- <span class="font-bold tracking-tight">{{ $t("about.no_integrity_violations") }}</span>
+ <span class="font-mono text-xs font-bold">v{{ appInfo.python_version }}</span>
+ </div>
+ <div class="flex items-center justify-between">
+ <span class="text-[10px] font-black text-purple-500 uppercase tracking-wider"
+ >LXMF</span
+ >
+ <span class="font-mono text-xs font-bold">v{{ appInfo.lxmf_version }}</span>
+ </div>
+ <div class="flex items-center justify-between">
+ <span class="text-[10px] font-black text-indigo-500 uppercase tracking-wider"
+ >RNS</span
+ >
+ <span class="font-mono text-xs font-bold">v{{ appInfo.rns_version }}</span>
+ </div>
</div>
</div>
+ </div>
- <!-- Dependency Chain -->
- <div v-if="appInfo" class="glass-card !p-6">
- <div
- class="text-xs font-black text-blue-500 uppercase tracking-[0.2em] mb-8 flex items-center gap-2"
- >
- <v-icon icon="mdi-link-variant" size="14"></v-icon>
- Dependency Chain
- </div>
- <div class="grid grid-cols-1 lg:grid-cols-2 gap-12 relative">
- <div class="flex flex-col space-y-8">
- <div class="flex items-center gap-5">
- <div
- class="w-12 h-12 rounded-2xl bg-blue-500/10 flex items-center justify-center border border-blue-500/20 shadow-sm"
- >
- <img
- src="../../public/favicons/favicon-512x512.png"
- class="w-7 h-7 object-contain"
- />
- </div>
- <div>
- <div class="text-sm font-black text-gray-900 dark:text-white">
- MeshChatX
- </div>
- <div class="text-xs font-mono font-bold text-gray-400">
- v{{ appInfo.version }}
- </div>
+ <!-- Dependency Chain -->
+ <div v-if="appInfo" class="glass-card !p-6">
+ <div
+ class="text-xs font-black text-blue-500 uppercase tracking-[0.2em] mb-8 flex items-center gap-2"
+ >
+ <v-icon icon="mdi-link-variant" size="14"></v-icon>
+ Dependency Chain
+ </div>
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-12 relative">
+ <div class="flex flex-col space-y-8">
+ <div class="flex items-center gap-5">
+ <div
+ class="w-12 h-12 rounded-2xl bg-blue-500/10 flex items-center justify-center border border-blue-500/20 shadow-sm"
+ >
+ <img
+ src="../../public/favicons/favicon-512x512.png"
+ class="w-7 h-7 object-contain"
+ />
+ </div>
+ <div>
+ <div class="text-sm font-black text-gray-900 dark:text-white">MeshChatX</div>
+ <div class="text-xs font-mono font-bold text-gray-400">
+ v{{ appInfo.version }}
</div>
</div>
+ </div>
+ <div
+ class="flex items-center gap-5 pl-5 border-l-2 border-zinc-100 dark:border-zinc-800 ml-6 relative"
+ >
+ <div
+ class="absolute -left-[2px] top-0 bottom-0 w-[2px] bg-gradient-to-b from-blue-500 to-purple-500"
+ ></div>
<div
- class="flex items-center gap-5 pl-5 border-l-2 border-zinc-100 dark:border-zinc-800 ml-6 relative"
+ class="w-12 h-12 rounded-2xl bg-purple-500/10 flex items-center justify-center border border-purple-500/20 text-purple-600 font-black text-[10px] tracking-tighter shadow-sm"
>
- <div
- class="absolute -left-[2px] top-0 bottom-0 w-[2px] bg-gradient-to-b from-blue-500 to-purple-500"
- ></div>
- <div
- class="w-12 h-12 rounded-2xl bg-purple-500/10 flex items-center justify-center border border-purple-500/20 text-purple-600 font-black text-[10px] tracking-tighter shadow-sm"
- >
- LXMF
+ LXMF
+ </div>
+ <div>
+ <div class="text-sm font-black text-gray-900 dark:text-white leading-tight">
+ Lightweight Extensible Message Format
</div>
- <div>
- <div class="text-sm font-black text-gray-900 dark:text-white leading-tight">
- Lightweight Extensible Message Format
- </div>
- <div class="text-xs font-mono font-bold text-gray-400 mt-1">
- v{{ appInfo.lxmf_version }}
- </div>
+ <div class="text-xs font-mono font-bold text-gray-400 mt-1">
+ v{{ appInfo.lxmf_version }}
</div>
</div>
+ </div>
+ <div
+ class="flex items-center gap-5 pl-5 border-l-2 border-zinc-100 dark:border-zinc-800 ml-6 relative"
+ >
+ <div
+ class="absolute -left-[2px] top-0 bottom-0 w-[2px] bg-gradient-to-b from-purple-500 to-indigo-500"
+ ></div>
<div
- class="flex items-center gap-5 pl-5 border-l-2 border-zinc-100 dark:border-zinc-800 ml-6 relative"
+ class="w-12 h-12 rounded-2xl bg-indigo-500/10 flex items-center justify-center border border-indigo-500/20 text-indigo-600 font-black text-[10px] tracking-tighter shadow-sm"
>
- <div
- class="absolute -left-[2px] top-0 bottom-0 w-[2px] bg-gradient-to-b from-purple-500 to-indigo-500"
- ></div>
- <div
- class="w-12 h-12 rounded-2xl bg-indigo-500/10 flex items-center justify-center border border-indigo-500/20 text-indigo-600 font-black text-[10px] tracking-tighter shadow-sm"
- >
- RNS
+ RNS
+ </div>
+ <div>
+ <div class="text-sm font-black text-gray-900 dark:text-white leading-tight">
+ Reticulum Network Stack
</div>
- <div>
- <div class="text-sm font-black text-gray-900 dark:text-white leading-tight">
- Reticulum Network Stack
- </div>
- <div class="text-xs font-mono font-bold text-gray-400 mt-1">
- v{{ appInfo.rns_version }}
- </div>
+ <div class="text-xs font-mono font-bold text-gray-400 mt-1">
+ v{{ appInfo.rns_version }}
</div>
</div>
</div>
+ </div>
- <div class="space-y-8">
+ <div class="space-y-8">
+ <div
+ class="bg-zinc-50 dark:bg-zinc-950 p-5 rounded-2xl border border-zinc-100 dark:border-zinc-800"
+ >
<div
- class="bg-zinc-50 dark:bg-zinc-950 p-5 rounded-2xl border border-zinc-100 dark:border-zinc-800"
+ class="text-[10px] font-black text-gray-400 dark:text-zinc-600 uppercase tracking-[0.2em] mb-4"
>
- <div
- class="text-[10px] font-black text-gray-400 dark:text-zinc-600 uppercase tracking-[0.2em] mb-4"
- >
- Core Runtime
+ Core Runtime
+ </div>
+ <div class="grid grid-cols-2 gap-x-6 gap-y-4">
+ <div v-if="appInfo.lxst_version" class="flex flex-col">
+ <span class="text-[9px] font-black text-blue-500/60 uppercase leading-none"
+ >LXST Engine</span
+ >
+ <span
+ class="text-[11px] font-mono font-bold mt-1.5 opacity-90 tracking-tight"
+ >v{{ appInfo.lxst_version }}</span
+ >
</div>
- <div class="grid grid-cols-2 gap-x-6 gap-y-4">
- <div v-if="appInfo.lxst_version" class="flex flex-col">
- <span
- class="text-[9px] font-black text-blue-500/60 uppercase leading-none"
- >LXST Engine</span
- >
- <span
- class="text-[11px] font-mono font-bold mt-1.5 opacity-90 tracking-tight"
- >v{{ appInfo.lxst_version }}</span
- >
- </div>
- <div v-if="electronVersion" class="flex flex-col">
- <span
- class="text-[9px] font-black text-blue-500/60 uppercase leading-none"
- >Electron</span
- >
- <span
- class="text-[11px] font-mono font-bold mt-1.5 opacity-90 tracking-tight"
- >v{{ electronVersion }}</span
- >
- </div>
- <div v-if="chromeVersion" class="flex flex-col">
- <span
- class="text-[9px] font-black text-blue-500/60 uppercase leading-none"
- >Chrome</span
- >
- <span
- class="text-[11px] font-mono font-bold mt-1.5 opacity-90 tracking-tight"
- >v{{ chromeVersion }}</span
- >
- </div>
- <div v-if="nodeVersion" class="flex flex-col">
- <span
- class="text-[9px] font-black text-blue-500/60 uppercase leading-none"
- >Node.js</span
- >
- <span
- class="text-[11px] font-mono font-bold mt-1.5 opacity-90 tracking-tight"
- >v{{ nodeVersion }}</span
- >
- </div>
+ <div v-if="electronVersion" class="flex flex-col">
+ <span class="text-[9px] font-black text-blue-500/60 uppercase leading-none"
+ >Electron</span
+ >
+ <span
+ class="text-[11px] font-mono font-bold mt-1.5 opacity-90 tracking-tight"
+ >v{{ electronVersion }}</span
+ >
+ </div>
+ <div v-if="chromeVersion" class="flex flex-col">
+ <span class="text-[9px] font-black text-blue-500/60 uppercase leading-none"
+ >Chrome</span
+ >
+ <span
+ class="text-[11px] font-mono font-bold mt-1.5 opacity-90 tracking-tight"
+ >v{{ chromeVersion }}</span
+ >
+ </div>
+ <div v-if="nodeVersion" class="flex flex-col">
+ <span class="text-[9px] font-black text-blue-500/60 uppercase leading-none"
+ >Node.js</span
+ >
+ <span
+ class="text-[11px] font-mono font-bold mt-1.5 opacity-90 tracking-tight"
+ >v{{ nodeVersion }}</span
+ >
</div>
</div>
+ </div>
- <div v-if="appInfo.dependencies" class="pt-2">
+ <div v-if="appInfo.dependencies" class="pt-2">
+ <div
+ class="text-[10px] font-black text-gray-400 dark:text-zinc-600 uppercase tracking-[0.2em] mb-4"
+ >
+ Backend Stack
+ </div>
+ <div class="grid grid-cols-2 sm:grid-cols-3 gap-x-6 gap-y-4">
<div
- class="text-[10px] font-black text-gray-400 dark:text-zinc-600 uppercase tracking-[0.2em] mb-4"
+ v-for="(version, name) in appInfo.dependencies"
+ :key="name"
+ class="flex flex-col"
>
- Backend Stack
- </div>
- <div class="grid grid-cols-2 sm:grid-cols-3 gap-x-6 gap-y-4">
- <div
- v-for="(version, name) in appInfo.dependencies"
- :key="name"
- class="flex flex-col"
+ <span
+ class="text-[9px] font-black text-gray-400 dark:text-zinc-600 uppercase truncate leading-none"
+ >{{ name.replace("_", " ") }}</span
+ >
+ <span
+ class="text-[10px] font-mono font-bold mt-1.5 opacity-50 tracking-tight"
+ >v{{ version }}</span
>
- <span
- class="text-[9px] font-black text-gray-400 dark:text-zinc-600 uppercase truncate leading-none"
- >{{ name.replace("_", " ") }}</span
- >
- <span
- class="text-[10px] font-mono font-bold mt-1.5 opacity-50 tracking-tight"
- >v{{ version }}</span
- >
- </div>
</div>
</div>
</div>
</div>
</div>
+ </div>
- <!-- Database Health -->
- <div class="glass-card !p-6">
- <div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mb-8">
- <div
- class="text-xs font-black text-blue-500 uppercase tracking-[0.2em] flex items-center gap-2"
+ <!-- Database Health -->
+ <div class="glass-card !p-6">
+ <div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mb-8">
+ <div
+ class="text-xs font-black text-blue-500 uppercase tracking-[0.2em] flex items-center gap-2"
+ >
+ <v-icon icon="mdi-database-cog" size="14"></v-icon>
+ Database Health & Maintenance
+ </div>
+ <div class="flex flex-wrap gap-2">
+ <button
+ type="button"
+ class="secondary-chip !px-4 !py-1.5 !text-xs"
+ :disabled="databaseActionInProgress || healthLoading"
+ @click="getDatabaseHealth(true)"
>
- <v-icon icon="mdi-database-cog" size="14"></v-icon>
- Database Health & Maintenance
- </div>
- <div class="flex flex-wrap gap-2">
- <v-btn
- variant="tonal"
- size="small"
- class="font-black px-4 rounded-lg"
- :loading="healthLoading"
- :disabled="databaseActionInProgress"
- @click="getDatabaseHealth(true)"
- >
- <v-icon icon="mdi-refresh" start size="14"></v-icon> Refresh
- </v-btn>
- <v-btn
- variant="flat"
- color="blue"
- size="small"
- class="font-black px-4 rounded-lg"
- :loading="databaseActionInProgress"
- @click="vacuumDatabase"
- >
- <v-icon icon="mdi-broom" start size="14"></v-icon> Vacuum
- </v-btn>
- <v-btn
- variant="flat"
- color="amber"
- size="small"
- class="font-black px-4 rounded-lg text-white"
- :loading="databaseActionInProgress"
- @click="runRecovery"
- >
- <v-icon icon="mdi-medical-bag" start size="14"></v-icon> Recovery
- </v-btn>
- </div>
+ <v-icon icon="mdi-refresh" start size="14"></v-icon>
+ <span v-if="healthLoading">Loading...</span>
+ <span v-else>Refresh</span>
+ </button>
+ <button
+ type="button"
+ class="primary-chip !px-4 !py-1.5 !text-xs"
+ :disabled="databaseActionInProgress"
+ @click="vacuumDatabase"
+ >
+ <v-icon icon="mdi-broom" start size="14"></v-icon> Vacuum
+ </button>
+ <button
+ type="button"
+ class="danger-chip !px-4 !py-1.5 !text-xs"
+ :disabled="databaseActionInProgress"
+ @click="runRecovery"
+ >
+ <v-icon icon="mdi-medical-bag" start size="14"></v-icon> Recovery
+ </button>
</div>
+ </div>
- <div v-if="databaseHealth" class="grid grid-cols-2 md:grid-cols-4 gap-8 mb-8">
+ <div v-if="databaseHealth" class="grid grid-cols-2 md:grid-cols-4 gap-8 mb-8">
+ <div
+ class="bg-zinc-50 dark:bg-zinc-950 p-4 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-inner"
+ >
<div
- class="bg-zinc-50 dark:bg-zinc-950 p-4 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-inner"
+ class="text-[9px] font-black text-gray-400 dark:text-zinc-600 uppercase tracking-[0.2em] mb-2 leading-none"
>
- <div
- class="text-[9px] font-black text-gray-400 dark:text-zinc-600 uppercase tracking-[0.2em] mb-2 leading-none"
- >
- Integrity
- </div>
- <div
- :class="[
- databaseHealth.quick_check === 'ok' ? 'text-emerald-500' : 'text-red-500',
- ]"
- class="text-lg font-black uppercase tracking-tight"
- >
- {{ databaseHealth.quick_check }}
- </div>
+ Integrity
</div>
<div
- class="bg-zinc-50 dark:bg-zinc-950 p-4 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-inner"
+ :class="[databaseHealth.quick_check === 'ok' ? 'text-emerald-500' : 'text-red-500']"
+ class="text-lg font-black uppercase tracking-tight"
>
- <div
- class="text-[9px] font-black text-gray-400 dark:text-zinc-600 uppercase tracking-[0.2em] mb-2 leading-none"
- >
- Journal
- </div>
- <div class="text-lg font-black uppercase text-blue-500 tracking-tight">
- {{ databaseHealth.journal_mode }}
- </div>
+ {{ databaseHealth.quick_check }}
</div>
+ </div>
+ <div
+ class="bg-zinc-50 dark:bg-zinc-950 p-4 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-inner"
+ >
<div
- class="bg-zinc-50 dark:bg-zinc-950 p-4 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-inner"
+ class="text-[9px] font-black text-gray-400 dark:text-zinc-600 uppercase tracking-[0.2em] mb-2 leading-none"
>
- <div
- class="text-[9px] font-black text-gray-400 dark:text-zinc-600 uppercase tracking-[0.2em] mb-2 leading-none"
- >
- Page Count
- </div>
- <div class="text-lg font-black font-mono tracking-tight tabular-nums">
- {{ databaseHealth.page_count }}
- </div>
+ Journal
</div>
+ <div class="text-lg font-black uppercase text-blue-500 tracking-tight">
+ {{ databaseHealth.journal_mode }}
+ </div>
+ </div>
+ <div
+ class="bg-zinc-50 dark:bg-zinc-950 p-4 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-inner"
+ >
<div
- class="bg-zinc-50 dark:bg-zinc-950 p-4 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-inner"
+ class="text-[9px] font-black text-gray-400 dark:text-zinc-600 uppercase tracking-[0.2em] mb-2 leading-none"
>
+ Page Count
+ </div>
+ <div class="text-lg font-black font-mono tracking-tight tabular-nums">
+ {{ databaseHealth.page_count }}
+ </div>
+ </div>
+ <div
+ class="bg-zinc-50 dark:bg-zinc-950 p-4 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-inner"
+ >
+ <div
+ class="text-[9px] font-black text-gray-400 dark:text-zinc-600 uppercase tracking-[0.2em] mb-2 leading-none"
+ >
+ Free Space
+ </div>
+ <div class="text-lg font-black text-amber-500 tracking-tight tabular-nums">
+ {{ formatBytes(databaseHealth.estimated_free_bytes) }}
+ </div>
+ </div>
+ </div>
+
+ <div class="border-t border-zinc-100 dark:border-zinc-800 pt-8 space-y-8">
+ <!-- Backups -->
+ <div class="flex flex-col md:flex-row md:items-center justify-between gap-6">
+ <div class="space-y-1">
<div
- class="text-[9px] font-black text-gray-400 dark:text-zinc-600 uppercase tracking-[0.2em] mb-2 leading-none"
+ class="font-black text-gray-900 dark:text-white text-sm tracking-tight flex items-center gap-2"
>
- Free Space
+ <v-icon icon="mdi-content-save-all" size="16" class="text-blue-500"></v-icon>
+ Database Backups
</div>
- <div class="text-lg font-black text-amber-500 tracking-tight tabular-nums">
- {{ formatBytes(databaseHealth.estimated_free_bytes) }}
+ <div class="text-xs text-gray-500">
+ Full snapshots of your communications database.
</div>
</div>
+ <button
+ type="button"
+ class="primary-chip !px-5 !py-2.5"
+ :disabled="backupInProgress"
+ @click="backupDatabase"
+ >
+ <v-icon icon="mdi-download" start></v-icon>
+ <span v-if="backupInProgress">Downloading...</span>
+ <span v-else>Download Backup</span>
+ </button>
</div>
- <div class="border-t border-zinc-100 dark:border-zinc-800 pt-8 space-y-8">
- <!-- Backups -->
- <div class="flex flex-col md:flex-row md:items-center justify-between gap-6">
+ <!-- Snapshots -->
+ <div class="space-y-6">
+ <div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div class="space-y-1">
<div
class="font-black text-gray-900 dark:text-white text-sm tracking-tight flex items-center gap-2"
>
- <v-icon
- icon="mdi-content-save-all"
- size="16"
- class="text-blue-500"
- ></v-icon>
- Database Backups
+ <v-icon icon="mdi-camera" size="16" class="text-purple-500"></v-icon>
+ Local Snapshots
</div>
<div class="text-xs text-gray-500">
- Full snapshots of your communications database.
+ Create point-in-time restore points on disk.
</div>
</div>
- <v-btn
- variant="tonal"
- color="blue"
- class="font-black rounded-xl"
- :loading="backupInProgress"
- @click="backupDatabase"
- >
- <v-icon icon="mdi-download" start></v-icon>
- Download Backup
- </v-btn>
+ <div class="flex gap-2 w-full md:w-auto">
+ <input
+ v-model="snapshotName"
+ type="text"
+ placeholder="Snapshot label..."
+ class="bg-zinc-50 dark:bg-zinc-900 px-4 py-2 rounded-xl text-sm border border-zinc-100 dark:border-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-500/20 flex-1 md:min-w-[200px]"
+ />
+ <button
+ type="button"
+ class="primary-chip !px-6"
+ :disabled="snapshotInProgress"
+ @click="createSnapshot"
+ >
+ <span v-if="snapshotInProgress">Creating...</span>
+ <span v-else>Create</span>
+ </button>
+ </div>
</div>
- <!-- Snapshots -->
- <div class="space-y-6">
- <div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
- <div class="space-y-1">
- <div
- class="font-black text-gray-900 dark:text-white text-sm tracking-tight flex items-center gap-2"
+ <div v-if="snapshots.length > 0" class="grid gap-3 sm:grid-cols-2">
+ <div
+ v-for="snapshot in snapshots"
+ :key="snapshot.path"
+ class="flex items-center justify-between p-4 rounded-2xl bg-zinc-50 dark:bg-zinc-900 border border-zinc-100 dark:border-zinc-800 hover:border-purple-500/20 transition-all group"
+ >
+ <div class="flex flex-col">
+ <span
+ class="font-black text-gray-900 dark:text-white text-xs truncate max-w-[150px]"
+ >{{ snapshot.name }}</span
>
- <v-icon icon="mdi-camera" size="16" class="text-purple-500"></v-icon>
- Local Snapshots
- </div>
- <div class="text-xs text-gray-500">
- Create point-in-time restore points on disk.
- </div>
- </div>
- <div class="flex gap-2 w-full md:w-auto">
- <input
- v-model="snapshotName"
- type="text"
- placeholder="Snapshot label..."
- class="bg-zinc-50 dark:bg-zinc-900 px-4 py-2 rounded-xl text-sm border border-zinc-100 dark:border-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-500/20 flex-1 md:min-w-[200px]"
- />
- <v-btn
- color="purple"
- class="font-black rounded-xl"
- :loading="snapshotInProgress"
- @click="createSnapshot"
+ <span class="text-[10px] font-bold text-gray-400 mt-1 tabular-nums"
+ >{{ formatBytes(snapshot.size) }} • {{ snapshot.created_at }}</span
>
- Create
- </v-btn>
</div>
- </div>
-
- <div v-if="snapshots.length > 0" class="grid gap-3 sm:grid-cols-2">
- <div
- v-for="snapshot in snapshots"
- :key="snapshot.path"
- class="flex items-center justify-between p-4 rounded-2xl bg-zinc-50 dark:bg-zinc-900 border border-zinc-100 dark:border-zinc-800 hover:border-purple-500/20 transition-all group"
+ <button
+ type="button"
+ class="secondary-chip !px-3 !py-1 !text-[10px] opacity-0 group-hover:opacity-100"
+ @click="restoreFromSnapshot(snapshot.path)"
>
- <div class="flex flex-col">
- <span
- class="font-black text-gray-900 dark:text-white text-xs truncate max-w-[150px]"
- >{{ snapshot.name }}</span
- >
- <span class="text-[10px] font-bold text-gray-400 mt-1 tabular-nums"
- >{{ formatBytes(snapshot.size) }} • {{ snapshot.created_at }}</span
- >
- </div>
- <v-btn
- variant="tonal"
- size="x-small"
- color="purple"
- class="font-black opacity-0 group-hover:opacity-100 transition-opacity"
- @click="restoreFromSnapshot(snapshot.path)"
- >
- Restore
- </v-btn>
- </div>
+ Restore
+ </button>
</div>
</div>
+ </div>
- <!-- Identity Section -->
- <div class="bg-red-500/5 p-6 rounded-2xl border border-red-500/10 space-y-6">
- <div class="flex items-center gap-4 text-red-500">
- <v-icon icon="mdi-key-alert" size="24"></v-icon>
- <div class="space-y-0.5">
- <div class="font-black text-sm tracking-tight">Identity Key Control</div>
- <div
- class="text-[10px] font-bold uppercase tracking-widest opacity-70 italic"
- >
- Critical Security Warning
- </div>
+ <!-- Identity Section -->
+ <div class="bg-red-500/5 p-6 rounded-2xl border border-red-500/10 space-y-6">
+ <div class="flex items-center gap-4 text-red-500">
+ <v-icon icon="mdi-key-alert" size="24"></v-icon>
+ <div class="space-y-0.5">
+ <div class="font-black text-sm tracking-tight">Identity Key Control</div>
+ <div class="text-[10px] font-bold uppercase tracking-widest opacity-70 italic">
+ Critical Security Warning
</div>
</div>
+ </div>
- <div class="flex flex-wrap gap-3">
- <v-btn
- variant="flat"
- color="red"
- class="font-black rounded-xl"
- @click="downloadIdentityFile"
+ <div class="flex flex-wrap gap-3">
+ <button
+ type="button"
+ class="danger-chip !px-5 !py-2.5"
+ @click="downloadIdentityFile"
+ >
+ <v-icon icon="mdi-file-export" start></v-icon>
+ Export Key File
+ </button>
+ <button
+ type="button"
+ class="secondary-chip !border-red-200 dark:!border-red-900/50 !text-red-600 dark:!text-red-400 !px-5 !py-2.5"
+ @click="copyIdentityBase32"
+ >
+ <v-icon icon="mdi-content-copy" start></v-icon>
+ Copy Base32 Key
+ </button>
+ </div>
+
+ <div class="space-y-4 pt-4 border-t border-red-500/10">
+ <div class="text-[10px] font-black text-red-500/60 uppercase tracking-widest">
+ Restore Identity
+ </div>
+ <div class="flex flex-col sm:flex-row gap-3">
+ <button
+ type="button"
+ class="secondary-chip flex-1 !border-dashed !border-2 !rounded-2xl !py-4"
+ @click="$refs.identityFileInput.click()"
+ >
+ <v-icon icon="mdi-upload" start></v-icon>
+ Upload Key File
+ </button>
+ <input
+ ref="identityFileInput"
+ type="file"
+ accept=".identity,.bin,.key"
+ class="hidden"
+ @change="onIdentityRestoreFileChange"
+ />
+ <div
+ class="text-center sm:py-2 text-[10px] font-black text-zinc-400 uppercase italic px-2 shrink-0 self-center"
>
- <v-icon icon="mdi-file-export" start></v-icon>
- Export Key File
- </v-btn>
- <v-btn
- variant="tonal"
- color="red"
- class="font-black rounded-xl"
- @click="copyIdentityBase32"
+ — or —
+ </div>
+ <button
+ type="button"
+ class="secondary-chip flex-1 !border-dashed !border-2 !rounded-2xl !py-4"
+ @click="showIdentityPaste = !showIdentityPaste"
>
- <v-icon icon="mdi-content-copy" start></v-icon>
- Copy Base32 Key
- </v-btn>
+ <v-icon icon="mdi-clipboard-text" start></v-icon>
+ Paste Base32
+ </button>
</div>
- <div class="space-y-4 pt-4 border-t border-red-500/10">
- <div class="text-[10px] font-black text-red-500/60 uppercase tracking-widest">
- Restore Identity
- </div>
- <div class="flex flex-col sm:flex-row gap-3">
- <v-btn
- variant="tonal"
- color="zinc"
- class="font-black rounded-xl flex-1 border-dashed border-2"
- @click="$refs.identityFileInput.click()"
+ <transition name="fade-blur">
+ <div v-if="showIdentityPaste" class="space-y-3">
+ <textarea
+ v-model="identityRestoreBase32"
+ rows="4"
+ placeholder="Paste your base32 identity key here..."
+ class="w-full bg-white dark:bg-zinc-950 p-4 rounded-xl font-mono text-xs border border-zinc-100 dark:border-zinc-800 focus:outline-none focus:ring-2 focus:ring-red-500/20"
+ ></textarea>
+ <button
+ type="button"
+ class="danger-chip w-full !py-3 !rounded-xl"
+ :disabled="identityRestoreInProgress"
+ @click="restoreIdentityBase32"
>
- <v-icon icon="mdi-upload" start></v-icon>
- Upload Key File
- </v-btn>
- <input
- ref="identityFileInput"
- type="file"
- accept=".identity,.bin,.key"
- class="hidden"
- @change="onIdentityRestoreFileChange"
- />
- <div
- class="text-center sm:py-2 text-[10px] font-black text-zinc-400 uppercase italic px-2 shrink-0 self-center"
- >
- — or —
- </div>
- <v-btn
- variant="tonal"
- color="zinc"
- class="font-black rounded-xl flex-1 border-dashed border-2"
- @click="showIdentityPaste = !showIdentityPaste"
- >
- <v-icon icon="mdi-clipboard-text" start></v-icon>
- Paste Base32
- </v-btn>
+ <span v-if="identityRestoreInProgress">Restoring...</span>
+ <span v-else>Confirm Key Restore</span>
+ </button>
</div>
-
- <transition name="fade-blur">
- <div v-if="showIdentityPaste" class="space-y-3">
- <textarea
- v-model="identityRestoreBase32"
- rows="4"
- placeholder="Paste your base32 identity key here..."
- class="w-full bg-white dark:bg-zinc-950 p-4 rounded-xl font-mono text-xs border border-zinc-100 dark:border-zinc-800 focus:outline-none focus:ring-2 focus:ring-red-500/20"
- ></textarea>
- <v-btn
- block
- color="red"
- class="font-black rounded-xl"
- :loading="identityRestoreInProgress"
- @click="restoreIdentityBase32"
- >
- Confirm Key Restore
- </v-btn>
- </div>
- </transition>
- </div>
+ </transition>
</div>
</div>
</div>
</div>
- </transition>
+ </div>
</div>
</div>
</div>
@@ -715,6 +656,7 @@ import Utils from "../../js/Utils";
import ElectronUtils from "../../js/ElectronUtils";
import DialogUtils from "../../js/DialogUtils";
import ToastUtils from "../../js/ToastUtils";
+import GlobalEmitter from "../../js/GlobalEmitter";
export default {
name: "AboutPage",
components: {},
@@ -758,7 +700,6 @@ export default {
electronVersion: null,
chromeVersion: null,
nodeVersion: null,
- showAdvanced: false,
showIdentityPaste: false,
};
},
@@ -1030,10 +971,10 @@ export default {
}
},
showChangelog() {
- this.$router.push({ name: "changelog" });
+ GlobalEmitter.emit("show-changelog");
},
showTutorial() {
- this.$router.push({ name: "tutorial" });
+ GlobalEmitter.emit("show-tutorial");
},
showReticulumConfigFile() {
const reticulumConfigPath = this.appInfo.reticulum_config_path;
@@ -1178,3 +1119,10 @@ export default {
},
};
</script>
+
+<style scoped>
+:deep(.about-btn:focus-visible) {
+ outline: 2px solid rgba(59, 130, 246, 0.35);
+ outline-offset: 2px;
+}
+</style>

diff --git a/meshchatx/src/frontend/components/blocked/BlockedPage.vue b/meshchatx/src/frontend/components/blocked/BlockedPage.vue
index 80603ade..57a3b6d8 100644
--- a/meshchatx/src/frontend/components/blocked/BlockedPage.vue
+++ b/meshchatx/src/frontend/components/blocked/BlockedPage.vue
@@ -8,8 +8,8 @@
<MaterialDesignIcon icon-name="block-helper" class="size-6 text-red-600 dark:text-red-400" />
</div>
<div>
- <h1 class="text-xl font-bold text-gray-900 dark:text-white">Blocked</h1>
- <p class="text-sm text-gray-500 dark:text-gray-400">Manage blocked users and nodes</p>
+ <h1 class="text-xl font-bold text-gray-900 dark:text-white">Banished</h1>
+ <p class="text-sm text-gray-500 dark:text-gray-400">Manage Banished users and nodes</p>
</div>
</div>
@@ -39,7 +39,7 @@
<div class="flex-1 overflow-y-auto p-4 md:p-6">
<div v-if="isLoading && blockedItems.length === 0" class="flex flex-col items-center justify-center h-64">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mb-4"></div>
- <p class="text-gray-500 dark:text-gray-400">Loading blocked items...</p>
+ <p class="text-gray-500 dark:text-gray-400">Loading banished items...</p>
</div>
<div
@@ -49,12 +49,12 @@
<div class="p-4 bg-gray-100 dark:bg-zinc-800 rounded-full mb-4 text-gray-400 dark:text-zinc-600">
<MaterialDesignIcon icon-name="check-circle" class="size-12" />
</div>
- <h3 class="text-lg font-medium text-gray-900 dark:text-white">No blocked items</h3>
+ <h3 class="text-lg font-medium text-gray-900 dark:text-white">No banished items</h3>
<p class="text-gray-500 dark:text-gray-400 max-w-sm mx-auto">
{{
searchQuery
- ? "No blocked items match your search."
- : "You haven't blocked any users or nodes yet."
+ ? "No banished items match your search."
+ : "You haven't banished any users or nodes yet."
}}
</p>
</div>
@@ -95,6 +95,13 @@
>
User
</span>
+ <span
+ v-if="item.is_rns_blackholed"
+ class="px-2 py-0.5 text-xs font-medium bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 rounded border border-zinc-200 dark:border-zinc-700"
+ title="Blackholed at Reticulum transport layer"
+ >
+ RNS Blackhole
+ </span>
</div>
<p
class="text-xs text-gray-500 dark:text-gray-400 font-mono break-all mt-1"
@@ -104,8 +111,20 @@
</p>
</div>
</div>
- <div class="text-xs text-gray-500 dark:text-gray-400">
- Blocked {{ formatTimeAgo(item.created_at) }}
+ <div v-if="item.created_at" class="text-xs text-gray-500 dark:text-gray-400 mb-1">
+ Banished {{ formatTimeAgo(item.created_at) }}
+ </div>
+ <div
+ v-if="item.rns_source"
+ class="text-[10px] text-zinc-500 dark:text-zinc-500 font-mono truncate mb-1"
+ >
+ Source: {{ item.rns_source }}
+ </div>
+ <div
+ v-if="item.rns_reason"
+ class="text-xs italic text-zinc-500 dark:text-zinc-400 mb-2"
+ >
+ "{{ item.rns_reason }}"
</div>
</div>
</div>
@@ -114,7 +133,7 @@
@click="onUnblock(item)"
>
<MaterialDesignIcon icon-name="check-circle" class="size-5" />
- <span>Unblock</span>
+ <span>Lift Banishment</span>
</button>
</div>
</div>
@@ -137,17 +156,31 @@ export default {
data() {
return {
blockedItems: [],
+ reticulumBlackholedItems: [],
isLoading: false,
searchQuery: "",
};
},
computed: {
+ allBlockedItems() {
+ // Combine local blocked items and reticulum blackholed items
+ // Prioritize local items if they overlap
+ const localHashes = new Set(this.blockedItems.map((i) => i.destination_hash));
+ const combined = [...this.blockedItems];
+
+ for (const item of this.reticulumBlackholedItems) {
+ if (!localHashes.has(item.destination_hash)) {
+ combined.push(item);
+ }
+ }
+ return combined;
+ },
filteredBlockedItems() {
if (!this.searchQuery.trim()) {
- return this.blockedItems;
+ return this.allBlockedItems;
}
const query = this.searchQuery.toLowerCase();
- return this.blockedItems.filter((item) => {
+ return this.allBlockedItems.filter((item) => {
const matchesHash = item.destination_hash.toLowerCase().includes(query);
const matchesDisplayName = (item.display_name || "").toLowerCase().includes(query);
return matchesHash || matchesDisplayName;
@@ -161,74 +194,70 @@ export default {
async loadBlockedDestinations() {
this.isLoading = true;
try {
+ // Load local blocked destinations
const response = await window.axios.get("/api/v1/blocked-destinations");
const blockedHashes = response.data.blocked_destinations || [];
- const items = await Promise.all(
- blockedHashes.map(async (blocked) => {
- let displayName = "Unknown";
- let isNode = false;
-
- try {
- const nodeAnnounceResponse = await window.axios.get("/api/v1/announces", {
- params: {
- aspect: "nomadnetwork.node",
- identity_hash: blocked.destination_hash,
- include_blocked: true,
- limit: 1,
- },
- });
+ // Load Reticulum blackholed identities
+ let reticulumBlackholed = {};
+ try {
+ const rnsResponse = await window.axios.get("/api/v1/reticulum/blackhole");
+ reticulumBlackholed = rnsResponse.data.blackholed_identities || {};
+ } catch (e) {
+ console.error("Failed to load Reticulum blackhole", e);
+ }
- if (nodeAnnounceResponse.data.announces && nodeAnnounceResponse.data.announces.length > 0) {
- const announce = nodeAnnounceResponse.data.announces[0];
- displayName = announce.display_name || "Unknown";
- isNode = true;
- } else {
- const announceResponse = await window.axios.get("/api/v1/announces", {
- params: {
- identity_hash: blocked.destination_hash,
- include_blocked: true,
- limit: 1,
- },
- });
+ const processItem = async (hash, data = {}) => {
+ let displayName = "Unknown";
+ let isNode = false;
- if (announceResponse.data.announces && announceResponse.data.announces.length > 0) {
- const announce = announceResponse.data.announces[0];
- displayName = announce.display_name || "Unknown";
- isNode = announce.aspect === "nomadnetwork.node";
- } else {
- const lxmfResponse = await window.axios.get("/api/v1/announces", {
- params: {
- destination_hash: blocked.destination_hash,
- include_blocked: true,
- limit: 1,
- },
- });
+ try {
+ const announceResponse = await window.axios.get("/api/v1/announces", {
+ params: {
+ identity_hash: hash,
+ include_blocked: true,
+ limit: 1,
+ },
+ });
- if (lxmfResponse.data.announces && lxmfResponse.data.announces.length > 0) {
- const announce = lxmfResponse.data.announces[0];
- displayName = announce.display_name || "Unknown";
- isNode = announce.aspect === "nomadnetwork.node";
- }
- }
- }
- } catch (e) {
- console.log(e);
+ if (announceResponse.data.announces && announceResponse.data.announces.length > 0) {
+ const announce = announceResponse.data.announces[0];
+ displayName = announce.display_name || "Unknown";
+ isNode = announce.aspect === "nomadnetwork.node";
}
+ } catch {
+ // ignore error
+ }
+
+ return {
+ destination_hash: hash,
+ display_name: displayName,
+ created_at: data.created_at || null,
+ is_node: isNode,
+ is_rns_blackholed: !!data.is_rns,
+ rns_source: data.source || null,
+ rns_reason: data.reason || null,
+ rns_until: data.until || null,
+ };
+ };
+
+ const items = await Promise.all(
+ blockedHashes.map((blocked) =>
+ processItem(blocked.destination_hash, { created_at: blocked.created_at })
+ )
+ );
- return {
- destination_hash: blocked.destination_hash,
- display_name: displayName,
- created_at: blocked.created_at,
- is_node: isNode,
- };
- })
+ const rnsItems = await Promise.all(
+ Object.entries(reticulumBlackholed).map(([hash, info]) =>
+ processItem(hash, { ...info, is_rns: true })
+ )
);
this.blockedItems = items;
+ this.reticulumBlackholedItems = rnsItems;
} catch (e) {
console.log(e);
- ToastUtils.error("Failed to load blocked destinations");
+ ToastUtils.error("Failed to load banished destinations");
} finally {
this.isLoading = false;
}
@@ -236,7 +265,7 @@ export default {
async onUnblock(item) {
if (
!(await DialogUtils.confirm(
- `Are you sure you want to unblock ${item.display_name || item.destination_hash}?`
+ `Are you sure you want to lift the banishment for ${item.display_name || item.destination_hash}?`
))
) {
return;
@@ -245,10 +274,10 @@ export default {
try {
await window.axios.delete(`/api/v1/blocked-destinations/${item.destination_hash}`);
await this.loadBlockedDestinations();
- ToastUtils.success("Unblocked successfully");
+ ToastUtils.success("Banishment lifted successfully");
} catch (e) {
console.log(e);
- ToastUtils.error("Failed to unblock");
+ ToastUtils.error("Failed to lift banishment");
}
},
onSearchInput() {},

diff --git a/meshchatx/src/frontend/components/call/CallPage.vue b/meshchatx/src/frontend/components/call/CallPage.vue
index 78585b22..eb10c634 100644
--- a/meshchatx/src/frontend/components/call/CallPage.vue
+++ b/meshchatx/src/frontend/components/call/CallPage.vue
@@ -2380,15 +2380,15 @@ export default {
}
},
async blockIdentity(hash) {
- if (!confirm(`Are you sure you want to block this identity?`)) return;
+ if (!confirm(`Are you sure you want to banish this identity?`)) return;
try {
await window.axios.post("/api/v1/blocked-destinations", {
destination_hash: hash,
});
- ToastUtils.success("Identity blocked");
+ ToastUtils.success("Identity banished");
this.getHistory();
} catch {
- ToastUtils.error("Failed to block identity");
+ ToastUtils.error("Failed to banish identity");
}
},
async getVoicemailStatus() {

diff --git a/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue b/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue
index 8a756423..46b53585 100644
--- a/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue
+++ b/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue
@@ -1588,12 +1588,6 @@ export default {
.glass-label {
@apply mb-1 text-sm font-semibold text-gray-800 dark:text-gray-200;
}
-.primary-chip {
- @apply inline-flex items-center gap-x-2 rounded-full bg-blue-600/90 px-3 py-1.5 text-xs font-semibold text-white shadow hover:bg-blue-500 transition;
-}
-.secondary-chip {
- @apply inline-flex items-center gap-x-2 rounded-full border border-gray-300 dark:border-zinc-700 px-3 py-1.5 text-xs font-semibold text-gray-700 dark:text-gray-100 bg-white/80 dark:bg-zinc-900/70 hover:border-blue-400;
-}
.glass-field {
@apply space-y-1;
}

diff --git a/meshchatx/src/frontend/components/map/MapPage.vue b/meshchatx/src/frontend/components/map/MapPage.vue
index 8da7ef57..ae39c2f7 100644
--- a/meshchatx/src/frontend/components/map/MapPage.vue
+++ b/meshchatx/src/frontend/components/map/MapPage.vue
@@ -78,12 +78,12 @@
<!-- drawing toolbar -->
<div class="absolute top-14 left-1/2 -translate-x-1/2 sm:top-2 z-20 flex flex-col gap-2 transform-gpu">
<div
- class="bg-white/70 dark:bg-zinc-900/60 backdrop-blur-md rounded-2xl shadow-2xl overflow-hidden flex flex-row p-1 gap-1 border-0"
+ class="bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl overflow-hidden flex flex-row p-1 gap-1 border-0"
>
<button
v-for="tool in drawingTools"
:key="tool.type"
- class="p-2.5 rounded-xl transition-all hover:scale-110 active:scale-90"
+ class="p-2 rounded-xl transition-all hover:scale-110 active:scale-90"
:class="[
(drawType === tool.type && !isMeasuring) || (tool.type === 'Export' && isExportMode)
? 'bg-blue-500 text-white shadow-lg shadow-blue-500/30'
@@ -92,11 +92,11 @@
:title="tool.type === 'Export' ? 'MBTiles exporter' : $t(`map.tool_${tool.type.toLowerCase()}`)"
@click="tool.type === 'Export' ? toggleExportMode() : toggleDraw(tool.type)"
>
- <v-icon :icon="'mdi-' + tool.icon" size="22"></v-icon>
+ <v-icon :icon="'mdi-' + tool.icon" size="20"></v-icon>
</button>
<div class="w-px h-6 bg-gray-200 dark:bg-zinc-800 my-auto mx-1"></div>
<button
- class="p-2.5 rounded-xl transition-all hover:scale-110 active:scale-90"
+ class="p-2 rounded-xl transition-all hover:scale-110 active:scale-90"
:class="[
isMeasuring
? 'bg-indigo-500 text-white shadow-lg shadow-indigo-500/30'
@@ -105,37 +105,37 @@
:title="$t('map.tool_measure')"
@click="toggleMeasure"
>
- <v-icon icon="mdi-ruler" size="22"></v-icon>
+ <v-icon icon="mdi-ruler" size="20"></v-icon>
</button>
<button
- class="p-2.5 rounded-xl hover:bg-red-50 dark:hover:bg-red-900/20 text-red-500 transition-all hover:scale-110 active:scale-90"
+ class="p-2 rounded-xl hover:bg-red-50 dark:hover:bg-red-900/20 text-red-500 transition-all hover:scale-110 active:scale-90"
:title="$t('map.tool_clear')"
@click="clearDrawings"
>
- <v-icon icon="mdi-trash-can-outline" size="22"></v-icon>
+ <v-icon icon="mdi-trash-can-outline" size="20"></v-icon>
</button>
<div class="w-px h-6 bg-gray-200 dark:bg-zinc-800 my-auto mx-1"></div>
<button
- class="p-2.5 rounded-xl hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-400 transition-all hover:scale-110 active:scale-90"
+ class="p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-400 transition-all hover:scale-110 active:scale-90"
:title="$t('map.save_drawing')"
@click="showSaveDrawingModal = true"
>
- <v-icon icon="mdi-content-save-outline" size="22"></v-icon>
+ <v-icon icon="mdi-content-save-outline" size="20"></v-icon>
</button>
<button
- class="p-2.5 rounded-xl hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-400 transition-all hover:scale-110 active:scale-90"
+ class="p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-400 transition-all hover:scale-110 active:scale-90"
:title="$t('map.load_drawing')"
@click="openLoadDrawingModal"
>
- <v-icon icon="mdi-folder-open-outline" size="22"></v-icon>
+ <v-icon icon="mdi-folder-open-outline" size="20"></v-icon>
</button>
<div class="w-px h-6 bg-gray-200 dark:bg-zinc-800 my-auto mx-1"></div>
<button
- class="p-2.5 rounded-xl hover:bg-blue-50 dark:hover:bg-blue-900/20 text-blue-500 transition-all hover:scale-110 active:scale-90"
+ class="p-2 rounded-xl hover:bg-blue-50 dark:hover:bg-blue-900/20 text-blue-500 transition-all hover:scale-110 active:scale-90"
:title="$t('map.go_to_my_location')"
@click="goToMyLocation"
>
- <v-icon icon="mdi-crosshairs-gps" size="22"></v-icon>
+ <v-icon icon="mdi-crosshairs-gps" size="20"></v-icon>
</button>
</div>
</div>
@@ -147,9 +147,7 @@
class="absolute top-2 left-4 right-4 sm:left-auto sm:right-4 sm:w-80 z-30"
>
<div class="relative">
- <div
- class="flex items-center bg-white/70 dark:bg-zinc-900/60 backdrop-blur-md rounded-xl shadow-2xl border-0 ring-0"
- >
+ <div class="flex items-center bg-white dark:bg-zinc-900 rounded-xl shadow-2xl border-0 ring-0">
<input
v-model="searchQuery"
type="text"
@@ -237,8 +235,8 @@
Edit Note
</span>
<button
- @click="closeNoteEditor"
class="text-gray-400 hover:text-gray-600 dark:hover:text-zinc-300"
+ @click="closeNoteEditor"
>
<MaterialDesignIcon icon-name="close" class="size-4" />
</button>
@@ -250,15 +248,15 @@
></textarea>
<div class="flex justify-between mt-3">
<button
- @click="deleteNote"
class="px-3 py-1.5 text-xs font-semibold text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors flex items-center gap-1"
+ @click="deleteNote"
>
<MaterialDesignIcon icon-name="trash-can-outline" class="size-3.5" />
Delete
</button>
<button
- @click="saveNote"
class="px-3 py-1.5 text-xs font-semibold bg-amber-500 text-white hover:bg-amber-600 rounded-lg shadow-sm transition-colors"
+ @click="saveNote"
>
Save
</button>
@@ -922,8 +920,8 @@
Edit Note
</h3>
<button
- @click="closeNoteEditor"
class="p-2 text-gray-400 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-full transition-colors"
+ @click="closeNoteEditor"
>
<MaterialDesignIcon icon-name="close" class="size-5" />
</button>
@@ -938,15 +936,15 @@
</div>
<div class="p-4 bg-gray-50 dark:bg-zinc-800/50 flex justify-between gap-3">
<button
- @click="deleteNote"
class="flex-1 px-4 py-3 text-sm font-bold text-red-500 hover:bg-red-100 dark:hover:bg-red-900/20 rounded-xl transition-colors flex items-center justify-center gap-2"
+ @click="deleteNote"
>
<MaterialDesignIcon icon-name="trash-can-outline" class="size-5" />
Delete
</button>
<button
- @click="saveNote"
class="flex-[2] px-4 py-3 text-sm font-bold bg-amber-500 text-white hover:bg-amber-600 rounded-xl shadow-lg shadow-amber-500/30 transition-colors"
+ @click="saveNote"
>
Save Note
</button>

diff --git a/meshchatx/src/frontend/components/messages/AudioWaveformPlayer.vue b/meshchatx/src/frontend/components/messages/AudioWaveformPlayer.vue
index a062e35d..d6cd0e41 100644
--- a/meshchatx/src/frontend/components/messages/AudioWaveformPlayer.vue
+++ b/meshchatx/src/frontend/components/messages/AudioWaveformPlayer.vue
@@ -1,6 +1,6 @@
<template>
<div
- class="audio-waveform-player flex items-center gap-3 p-2 rounded-xl transition-all w-full min-w-[240px]"
+ class="audio-waveform-player flex items-center gap-3 p-2 rounded-xl transition-all w-full min-w-0"
:class="[
isOutbound ? 'bg-white/10 text-white' : 'bg-gray-100 dark:bg-zinc-800/80 text-gray-800 dark:text-zinc-200',
]"

diff --git a/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue b/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue
index c09155c3..3b97f92b 100644
--- a/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue
@@ -28,11 +28,11 @@
<div class="border-t">
<DropDownMenuItem v-if="!isBlocked" @click="onBlockDestination">
<MaterialDesignIcon icon-name="block-helper" class="size-5 text-red-500" />
- <span class="text-red-500">Block User</span>
+ <span class="text-red-500">Banish User</span>
</DropDownMenuItem>
<DropDownMenuItem v-else @click="onUnblockDestination">
<MaterialDesignIcon icon-name="check-circle" class="size-5 text-green-500" />
- <span class="text-green-500">Unblock User</span>
+ <span class="text-green-500">Lift Banishment</span>
</DropDownMenuItem>
</div>
@@ -83,7 +83,7 @@ export default {
async onBlockDestination() {
if (
!(await DialogUtils.confirm(
- "Are you sure you want to block this user? They will not be able to send you messages or establish links."
+ "Are you sure you want to banish this user? They will not be able to send you messages or establish links."
))
) {
return;
@@ -94,10 +94,10 @@ export default {
destination_hash: this.peer.destination_hash,
});
GlobalEmitter.emit("block-status-changed");
- DialogUtils.alert("User blocked successfully");
+ DialogUtils.alert("User banished successfully");
this.$emit("block-status-changed");
} catch (e) {
- DialogUtils.alert("Failed to block user");
+ DialogUtils.alert("Failed to banish user");
console.log(e);
}
},
@@ -105,10 +105,10 @@ export default {
try {
await window.axios.delete(`/api/v1/blocked-destinations/${this.peer.destination_hash}`);
GlobalEmitter.emit("block-status-changed");
- DialogUtils.alert("User unblocked successfully");
+ DialogUtils.alert("Banishment lifted successfully");
this.$emit("block-status-changed");
} catch (e) {
- DialogUtils.alert("Failed to unblock user");
+ DialogUtils.alert("Failed to lift banishment");
console.log(e);
}
},

diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index d6b94d35..b72ed042 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -50,7 +50,7 @@
<MaterialDesignIcon icon-name="tag-outline" class="size-4" />
</div>
<div
- class="font-semibold text-gray-900 dark:text-zinc-100 truncate max-w-xs sm:max-w-sm text-base"
+ class="font-semibold text-gray-900 dark:text-zinc-100 truncate max-w-[120px] sm:max-w-sm text-base"
:title="selectedPeer.custom_display_name ?? selectedPeer.display_name"
>
{{ selectedPeer.custom_display_name ?? selectedPeer.display_name }}
@@ -193,16 +193,16 @@
class="h-full overflow-y-scroll bg-gray-50/30 dark:bg-zinc-950/50"
@scroll="onMessagesScroll"
>
- <div v-if="selectedPeerChatItems.length > 0" class="flex flex-col flex-col-reverse px-4 py-6">
+ <div v-if="selectedPeerChatItems.length > 0" class="flex flex-col flex-col-reverse px-4 py-6 min-w-0">
<div
v-for="chatItem of selectedPeerChatItemsReversed"
:key="chatItem.lxmf_message.hash"
- class="flex flex-col max-w-[75%] sm:max-w-[65%] lg:max-w-[55%] mb-4 group"
+ class="flex flex-col max-w-[85%] sm:max-w-[75%] lg:max-w-[65%] mb-4 group min-w-0"
:class="{ 'ml-auto items-end': chatItem.is_outbound, 'mr-auto items-start': !chatItem.is_outbound }"
>
<!-- message content -->
<div
- class="relative rounded-2xl overflow-hidden transition-all duration-200 hover:shadow-md"
+ class="relative rounded-2xl overflow-hidden transition-all duration-200 hover:shadow-md min-w-0"
:class="[
['cancelled', 'failed'].includes(chatItem.lxmf_message.state)
? 'bg-red-500 text-white shadow-sm'
@@ -214,7 +214,7 @@
]"
@click="onChatItemClick(chatItem)"
>
- <div class="w-full space-y-1 px-4 py-2.5">
+ <div class="w-full space-y-1 px-4 py-2.5 min-w-0">
<!-- spam badge -->
<div
v-if="chatItem.lxmf_message.is_spam"
@@ -243,7 +243,7 @@
<!-- content -->
<div
v-if="chatItem.lxmf_message.content"
- class="leading-relaxed whitespace-pre-wrap break-words"
+ class="leading-relaxed whitespace-pre-wrap break-words [word-break:break-word] min-w-0"
:style="{
'font-family': 'inherit',
'font-size': (config?.message_font_size || 14) + 'px',
@@ -296,14 +296,14 @@
<!-- paper message auto-conversion -->
<div
- v-if="getParsedItems(chatItem).paperMessage"
- class="flex flex-col gap-2 p-3 rounded-xl bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-100 dark:border-emerald-800/30"
+ v-if="getParsedItems(chatItem).paperMessage && !chatItem.is_outbound"
+ class="flex flex-col gap-2 p-3 rounded-xl bg-emerald-50 dark:bg-black/60 border border-emerald-100 dark:border-zinc-700/50"
>
- <div class="flex items-center gap-2 text-emerald-700 dark:text-emerald-300">
+ <div class="flex items-center gap-2 text-emerald-700 dark:text-emerald-400">
<MaterialDesignIcon icon-name="qrcode-scan" class="size-5" />
<span class="text-sm font-bold">Paper Message detected</span>
</div>
- <p class="text-xs text-emerald-600/80 dark:text-emerald-400/80 leading-relaxed">
+ <p class="text-xs text-emerald-600/80 dark:text-zinc-400 leading-relaxed">
This message contains a signed LXMF URI that can be ingested into your
conversations.
</p>
@@ -321,7 +321,7 @@
<div v-if="chatItem.lxmf_message.fields?.image" class="relative group mt-1 -mx-1">
<img
:src="`/api/v1/lxmf-messages/attachment/${chatItem.lxmf_message.hash}/image`"
- class="w-full rounded-lg cursor-pointer transition-transform group-hover:scale-[1.01]"
+ class="max-w-[240px] sm:max-w-xs w-full rounded-lg cursor-pointer transition-transform group-hover:scale-[1.01]"
@click.stop="
openImage(
`/api/v1/lxmf-messages/attachment/${chatItem.lxmf_message.hash}/image`
@@ -636,6 +636,7 @@
<div
v-for="(line, index) in getMessageInfoLines(chatItem.lxmf_message, chatItem.is_outbound)"
:key="index"
+ class="break-all"
>
{{ line }}
</div>
@@ -674,7 +675,7 @@
class="w-full border-t border-gray-200/60 dark:border-zinc-800/60 bg-white/80 dark:bg-zinc-900/50 backdrop-blur-sm px-3 sm:px-4 py-2.5"
>
<div class="w-full">
- <!-- blocked user notification -->
+ <!-- banished user notification -->
<div
v-if="isSelectedPeerBlocked"
class="mb-3 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg flex items-center gap-2"
@@ -694,7 +695,7 @@
/>
</svg>
<span class="text-sm text-yellow-800 dark:text-yellow-200"
- >You have blocked this user. They cannot send you messages or establish links.</span
+ >You have banished this user. They cannot send you messages or establish links.</span
>
</div>
@@ -914,13 +915,27 @@
class="group cursor-pointer p-4 bg-white dark:bg-zinc-900/50 border border-gray-100 dark:border-zinc-800 rounded-2xl hover:border-blue-500/50 hover:shadow-xl hover:shadow-blue-500/5 transition-all duration-300 flex items-center gap-4"
@click="$emit('update:selectedPeer', chat)"
>
- <LxmfUserIcon
- :custom-image="chat.contact_image"
- :icon-name="chat.lxmf_user_icon ? chat.lxmf_user_icon.icon_name : ''"
- :icon-foreground-colour="chat.lxmf_user_icon ? chat.lxmf_user_icon.foreground_colour : ''"
- :icon-background-colour="chat.lxmf_user_icon ? chat.lxmf_user_icon.background_colour : ''"
- icon-class="size-10"
- />
+ <div class="flex-shrink-0">
+ <LxmfUserIcon
+ :custom-image="chat.contact_image"
+ :icon-name="
+ chat.lxmf_user_icon && chat.lxmf_user_icon.icon_name
+ ? chat.lxmf_user_icon.icon_name
+ : 'account'
+ "
+ :icon-foreground-colour="
+ chat.lxmf_user_icon && chat.lxmf_user_icon.foreground_colour
+ ? chat.lxmf_user_icon.foreground_colour
+ : ''
+ "
+ :icon-background-colour="
+ chat.lxmf_user_icon && chat.lxmf_user_icon.background_colour
+ ? chat.lxmf_user_icon.background_colour
+ : ''
+ "
+ icon-class="size-12 sm:size-14"
+ />
+ </div>
<div class="flex-1 min-w-0">
<div class="font-bold text-gray-900 dark:text-zinc-100 truncate">
{{ chat.custom_display_name ?? chat.display_name }}
@@ -984,12 +999,14 @@
<PaperMessageModal
v-if="isPaperMessageModalOpen"
:message-hash="paperMessageHash"
+ :recipient-hash="selectedPeer?.destination_hash"
@close="isPaperMessageModalOpen = false"
/>
<PaperMessageModal
v-if="isPaperMessageResultModalOpen"
:initial-uri="generatedPaperMessageUri"
+ :recipient-hash="selectedPeer?.destination_hash"
@close="
isPaperMessageResultModalOpen = false;
generatedPaperMessageUri = null;
@@ -2763,7 +2780,16 @@ export default {
@apply inline-flex items-center justify-center text-gray-500 dark:text-gray-300 hover:text-red-500;
}
.attachment-action-button {
- @apply inline-flex items-center gap-1 rounded-full border border-gray-200 dark:border-zinc-700 bg-white/90 dark:bg-zinc-900/80 px-3 py-1.5 text-xs font-semibold text-gray-800 dark:text-gray-100 shadow-sm hover:border-blue-400 dark:hover:border-blue-500 transition;
+ @apply inline-flex items-center gap-1 rounded-full border border-gray-200 px-3 py-1.5 text-xs font-bold text-gray-700 bg-white shadow-sm transition-all !important;
+}
+.attachment-action-button:hover {
+ @apply bg-gray-50 text-gray-900 border-blue-400 !important;
+}
+.dark .attachment-action-button {
+ @apply border-zinc-700 text-zinc-100 bg-zinc-900 !important;
+}
+.dark .attachment-action-button:hover {
+ @apply bg-zinc-800 text-white border-blue-500 !important;
}
.audio-controls-light {

diff --git a/meshchatx/src/frontend/components/messages/MessagesPage.vue b/meshchatx/src/frontend/components/messages/MessagesPage.vue
index 270f3fd9..d41a6648 100644
--- a/meshchatx/src/frontend/components/messages/MessagesPage.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesPage.vue
@@ -38,6 +38,7 @@
:my-lxmf-address-hash="config?.lxmf_address_hash"
:selected-peer="selectedPeer"
:conversations="conversations"
+ @update:selected-peer="onPeerClick"
@close="onCloseConversationViewer"
@reload-conversations="getConversations"
/>

diff --git a/meshchatx/src/frontend/components/messages/PaperMessageModal.vue b/meshchatx/src/frontend/components/messages/PaperMessageModal.vue
index 73b278dc..b140c443 100644
--- a/meshchatx/src/frontend/components/messages/PaperMessageModal.vue
+++ b/meshchatx/src/frontend/components/messages/PaperMessageModal.vue
@@ -32,8 +32,8 @@
</div>
<template v-else-if="uri">
<!-- QR code container -->
- <div class="p-4 bg-white rounded-2xl shadow-inner border border-gray-100 mb-6 relative group">
- <div class="size-48 sm:size-64 flex items-center justify-center overflow-hidden">
+ <div class="p-3 bg-white rounded-2xl shadow-inner border border-gray-100 mb-6 relative group">
+ <div class="size-40 sm:size-48 flex items-center justify-center overflow-hidden">
<canvas ref="qrcode"></canvas>
</div>
<div
@@ -47,7 +47,61 @@
</div>
</div>
- <div class="w-full space-y-3">
+ <div v-if="recipientHash" class="w-full space-y-3">
+ <div
+ class="bg-gray-50 dark:bg-zinc-800/50 rounded-2xl p-3 border border-gray-100 dark:border-zinc-700/50"
+ >
+ <label
+ class="block text-[9px] font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-widest mb-1.5"
+ >
+ LXMF URI
+ </label>
+ <div class="flex gap-2">
+ <div
+ class="flex-1 font-mono text-[10px] break-all text-gray-600 dark:text-zinc-300 bg-white dark:bg-zinc-900 p-2 rounded-lg border border-gray-200 dark:border-zinc-700 max-h-20 overflow-y-auto"
+ >
+ {{ uri }}
+ </div>
+ <button
+ type="button"
+ class="size-9 flex items-center justify-center bg-white dark:bg-zinc-900 text-gray-500 dark:text-zinc-400 rounded-lg border border-gray-200 dark:border-zinc-700 hover:bg-blue-50 hover:text-blue-600 hover:border-blue-200 transition-all shadow-sm"
+ title="Copy URI"
+ @click="copyUri"
+ >
+ <MaterialDesignIcon icon-name="content-copy" class="size-4" />
+ </button>
+ </div>
+ </div>
+
+ <div class="flex gap-2 pt-1">
+ <button
+ type="button"
+ class="flex-1 flex items-center justify-center gap-2 py-2.5 px-4 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-bold shadow-lg shadow-blue-500/20 transition-all active:scale-[0.98] text-sm"
+ @click="printQRCode"
+ >
+ <MaterialDesignIcon icon-name="printer" class="size-4" />
+ Print
+ </button>
+ <button
+ type="button"
+ class="flex-1 flex items-center justify-center gap-2 py-2.5 px-4 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl font-bold shadow-lg shadow-emerald-500/20 transition-all active:scale-[0.98] text-sm"
+ :disabled="isSending"
+ @click="sendPaperMessage"
+ >
+ <template v-if="isSending">
+ <div
+ class="size-4 border-2 border-white/20 border-t-white rounded-full animate-spin"
+ ></div>
+ Sending...
+ </template>
+ <template v-else>
+ <MaterialDesignIcon icon-name="send" class="size-4" />
+ Send
+ </template>
+ </button>
+ </div>
+ </div>
+ <div v-else class="w-full space-y-3">
<div
class="bg-gray-50 dark:bg-zinc-800/50 rounded-2xl p-3 border border-gray-100 dark:border-zinc-700/50"
>
@@ -134,12 +188,18 @@ export default {
required: false,
default: null,
},
+ recipientHash: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
emits: ["close"],
data() {
return {
uri: this.initialUri,
isLoading: !this.initialUri,
+ isSending: false,
};
},
async mounted() {
@@ -173,7 +233,7 @@ export default {
try {
await QRCode.toCanvas(this.$refs.qrcode, this.uri, {
- width: 320,
+ width: 256,
margin: 2,
color: {
dark: "#000000",
@@ -211,11 +271,56 @@ export default {
const dataUrl = canvas.toDataURL("image/png");
if (dataUrl) {
const link = document.createElement("a");
- link.download = `lxmf-paper-message-${this.messageHash.substring(0, 8)}.png`;
+ link.download = `lxmf-paper-message-${this.messageHash ? this.messageHash.substring(0, 8) : Date.now()}.png`;
link.href = dataUrl;
link.click();
}
},
+ async sendPaperMessage() {
+ const canvas = this.$refs.qrcode;
+ if (!canvas || !this.recipientHash || !this.uri) return;
+
+ try {
+ this.isSending = true;
+
+ // get data url from canvas (format: data:image/png;base64,iVBORw0KG...)
+ const dataUrl = canvas.toDataURL("image/png");
+
+ // extract base64 data by removing the prefix
+ const imageBytes = dataUrl.split(",")[1];
+
+ // build lxmf message
+ const lxmf_message = {
+ destination_hash: this.recipientHash,
+ content: this.uri,
+ fields: {
+ image: {
+ image_type: "png",
+ image_bytes: imageBytes,
+ name: "qrcode.png",
+ },
+ },
+ };
+
+ // send message
+ const response = await window.axios.post(`/api/v1/lxmf-messages/send`, {
+ delivery_method: "opportunistic",
+ lxmf_message: lxmf_message,
+ });
+
+ if (response.data.status === "success") {
+ ToastUtils.success("Paper message sent successfully");
+ this.close();
+ } else {
+ ToastUtils.error(response.data.message || "Failed to send paper message");
+ }
+ } catch (err) {
+ console.error("Failed to send paper message:", err);
+ ToastUtils.error("Failed to send paper message");
+ } finally {
+ this.isSending = false;
+ }
+ },
printQRCode() {
const canvas = this.$refs.qrcode;
if (!canvas) return;

diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue
index cbe5b36e..e67efea3 100644
--- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue
+++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue
@@ -92,7 +92,7 @@
<div v-if="isBlocked(favourite.destination_hash)" class="border-t">
<DropDownMenuItem @click.stop="onUnblockNode(favourite.destination_hash)">
<MaterialDesignIcon icon-name="check-circle" class="w-5 h-5 text-green-500" />
- <span class="text-green-500">Unblock Node</span>
+ <span class="text-green-500">Lift Banishment</span>
</DropDownMenuItem>
</div>
</template>
@@ -169,7 +169,7 @@
</DropDownMenuItem>
<DropDownMenuItem v-else @click.stop="onUnblockNode(node.identity_hash)">
<MaterialDesignIcon icon-name="check-circle" class="w-5 h-5 text-green-500" />
- <span class="text-green-500">Unblock Node</span>
+ <span class="text-green-500">Lift Banishment</span>
</DropDownMenuItem>
</template>
</DropDownMenu>
@@ -321,9 +321,9 @@ export default {
try {
await window.axios.delete(`/api/v1/blocked-destinations/${identityHash}`);
GlobalEmitter.emit("block-status-changed");
- DialogUtils.alert("Node unblocked successfully");
+ DialogUtils.alert("Banishment lifted successfully");
} catch (e) {
- DialogUtils.alert("Failed to unblock node");
+ DialogUtils.alert("Failed to lift banishment");
console.log(e);
}
},

diff --git a/meshchatx/src/frontend/components/ping/PingPage.vue b/meshchatx/src/frontend/components/ping/PingPage.vue
index e7069a97..7f1cca39 100644
--- a/meshchatx/src/frontend/components/ping/PingPage.vue
+++ b/meshchatx/src/frontend/components/ping/PingPage.vue
@@ -40,28 +40,24 @@
</div>
<div class="flex flex-wrap gap-2">
- <button v-if="!isRunning" type="button" class="primary-chip px-4 py-2 text-sm" @click="start">
+ <button v-if="!isRunning" type="button" class="primary-chip" @click="start">
<MaterialDesignIcon icon-name="play" class="w-4 h-4" />
{{ $t("ping.start_ping") }}
</button>
<button
v-else
type="button"
- class="secondary-chip px-4 py-2 text-sm text-red-600 dark:text-red-300 border-red-200 dark:border-red-500/50"
+ class="secondary-chip !text-red-600 dark:!text-red-300 !border-red-200 dark:!border-red-500/50"
@click="stop"
>
<MaterialDesignIcon icon-name="pause" class="w-4 h-4" />
{{ $t("ping.stop") }}
</button>
- <button type="button" class="secondary-chip px-4 py-2 text-sm" @click="clear">
+ <button type="button" class="secondary-chip" @click="clear">
<MaterialDesignIcon icon-name="broom" class="w-4 h-4" />
{{ $t("ping.clear_results") }}
</button>
- <button
- type="button"
- class="inline-flex items-center gap-2 rounded-full bg-red-600/90 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-red-500 transition"
- @click="dropPath"
- >
+ <button type="button" class="danger-chip" @click="dropPath">
<MaterialDesignIcon icon-name="link-variant-remove" class="w-4 h-4" />
{{ $t("ping.drop_path") }}
</button>

diff --git a/meshchatx/src/frontend/components/rncp/RNCPPage.vue b/meshchatx/src/frontend/components/rncp/RNCPPage.vue
index 81a7f0b3..7ba3af8e 100644
--- a/meshchatx/src/frontend/components/rncp/RNCPPage.vue
+++ b/meshchatx/src/frontend/components/rncp/RNCPPage.vue
@@ -15,6 +15,33 @@
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ $t("rncp.description") }}
</div>
+
+ <div
+ class="mt-4 p-4 rounded-lg bg-blue-50/50 dark:bg-blue-900/10 border border-blue-100 dark:border-blue-900/20"
+ >
+ <div
+ class="text-xs font-bold uppercase tracking-wider text-blue-600 dark:text-blue-400 mb-2"
+ >
+ {{ $t("rncp.usage_steps") }}
+ </div>
+ <div class="space-y-1.5">
+ <!-- eslint-disable-next-line vue/no-v-html -->
+ <p
+ class="text-xs text-blue-800/80 dark:text-blue-300/80 leading-relaxed"
+ v-html="renderMarkdown($t('rncp.step_1'))"
+ ></p>
+ <!-- eslint-disable-next-line vue/no-v-html -->
+ <p
+ class="text-xs text-blue-800/80 dark:text-blue-300/80 leading-relaxed"
+ v-html="renderMarkdown($t('rncp.step_2'))"
+ ></p>
+ <!-- eslint-disable-next-line vue/no-v-html -->
+ <p
+ class="text-xs text-blue-800/80 dark:text-blue-300/80 leading-relaxed"
+ v-html="renderMarkdown($t('rncp.step_3'))"
+ ></p>
+ </div>
+ </div>
</div>
<div class="flex gap-2 border-b border-gray-200 dark:border-zinc-700">
@@ -493,6 +520,10 @@ export default {
this.listenDestinationHash = null;
this.listenResult = null;
},
+ renderMarkdown(text) {
+ if (!text) return "";
+ return text.replace(/\*\*(.*?)\*\*/g, "<b>$1</b>");
+ },
},
};
</script>

diff --git a/meshchatx/src/frontend/components/rnstatus/RNStatusPage.vue b/meshchatx/src/frontend/components/rnstatus/RNStatusPage.vue
index 92a6b384..8ff7d996 100644
--- a/meshchatx/src/frontend/components/rnstatus/RNStatusPage.vue
+++ b/meshchatx/src/frontend/components/rnstatus/RNStatusPage.vue
@@ -48,11 +48,36 @@
</div>
</div>
- <div
- v-if="linkCount !== null"
- class="p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300"
- >
- <div class="font-semibold">Active Links: {{ linkCount }}</div>
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div
+ v-if="linkCount !== null"
+ class="p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300"
+ >
+ <div class="font-semibold">Active Links: {{ linkCount }}</div>
+ </div>
+
+ <div
+ v-if="blackholeEnabled !== null"
+ class="p-3 rounded-lg bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300"
+ >
+ <div class="font-semibold flex justify-between items-center">
+ <span>Blackhole: {{ blackholeEnabled ? "Publishing" : "Active" }}</span>
+ <span class="text-sm opacity-80"> {{ blackholeCount }} Identities </span>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div v-if="blackholeSources.length > 0" class="glass-card space-y-3">
+ <div class="font-semibold text-lg text-gray-900 dark:text-white">Blackhole Sources</div>
+ <div class="grid gap-2">
+ <div
+ v-for="source in blackholeSources"
+ :key="source"
+ class="text-sm font-mono bg-gray-50 dark:bg-gray-800 p-2 rounded truncate"
+ >
+ {{ source }}
+ </div>
</div>
</div>
@@ -189,6 +214,9 @@ export default {
linkCount: null,
includeLinkStats: false,
sorting: "",
+ blackholeEnabled: null,
+ blackholeSources: [],
+ blackholeCount: 0,
};
},
watch: {
@@ -215,6 +243,9 @@ export default {
const response = await window.axios.get("/api/v1/rnstatus", { params });
this.interfaces = response.data.interfaces || [];
this.linkCount = response.data.link_count;
+ this.blackholeEnabled = response.data.blackhole_enabled;
+ this.blackholeSources = response.data.blackhole_sources || [];
+ this.blackholeCount = response.data.blackhole_count || 0;
} catch (e) {
console.error(e);
} finally {

diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue
index 0ee6f4aa..61c33406 100644
--- a/meshchatx/src/frontend/components/settings/SettingsPage.vue
+++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue
@@ -436,6 +436,40 @@
</div>
</section>
+ <!-- Network Security -->
+ <section class="glass-card break-inside-avoid">
+ <header class="glass-card__header">
+ <div>
+ <div class="glass-card__eyebrow">RNS Security</div>
+ <h2>Network Security</h2>
+ <p>Manage mesh-level security features.</p>
+ </div>
+ </header>
+ <div class="glass-card__body space-y-4">
+ <div class="setting-toggle">
+ <div class="setting-toggle__label">
+ <div class="setting-toggle__title">
+ {{ $t("app.blackhole_integration_enabled") }}
+ </div>
+ <div class="setting-toggle__description text-xs text-gray-500">
+ {{ $t("app.blackhole_integration_description") }}
+ </div>
+ </div>
+ <Toggle
+ v-model="config.blackhole_integration_enabled"
+ @update:model-value="
+ updateConfig(
+ {
+ blackhole_integration_enabled: config.blackhole_integration_enabled,
+ },
+ 'blackhole_integration_enabled'
+ )
+ "
+ />
+ </div>
+ </div>
+ </section>
+
<!-- Transport -->
<section class="glass-card break-inside-avoid">
<header class="glass-card__header">
@@ -494,14 +528,14 @@
<header class="glass-card__header">
<div>
<div class="glass-card__eyebrow">Privacy</div>
- <h2>Blocked</h2>
- <p>Manage blocked users and nodes</p>
+ <h2>Banished</h2>
+ <p>Manage Banished users and nodes</p>
</div>
- <RouterLink :to="{ name: 'blocked' }" class="primary-chip"> Manage Blocked </RouterLink>
+ <RouterLink :to="{ name: 'blocked' }" class="primary-chip"> Manage Banished </RouterLink>
</header>
<div class="glass-card__body">
<p class="text-sm text-gray-600 dark:text-gray-400">
- Blocked users and nodes will not be able to send you messages, and their announces will
+ Banished users and nodes will not be able to send you messages, and their announces will
be ignored.
</p>
</div>
@@ -867,6 +901,7 @@ export default {
banished_effect_enabled: true,
banished_text: "BANISHED",
banished_color: "#dc2626",
+ blackhole_integration_enabled: true,
},
saveTimeouts: {},
shortcuts: [],
@@ -1319,9 +1354,6 @@ export default {
.setting-toggle__hint {
@apply text-xs text-gray-500 dark:text-gray-400;
}
-.primary-chip {
- @apply inline-flex items-center gap-x-1 rounded-full bg-blue-600/90 px-4 py-1.5 text-xs font-semibold text-white shadow hover:bg-blue-500 transition;
-}
.info-callout {
@apply rounded-2xl border border-blue-100 dark:border-blue-900/40 bg-blue-50/60 dark:bg-blue-900/20 px-3 py-3 text-blue-900 dark:text-blue-100;
}

diff --git a/meshchatx/src/frontend/components/tools/PaperMessagePage.vue b/meshchatx/src/frontend/components/tools/PaperMessagePage.vue
index 9eaab521..74aaefd8 100644
--- a/meshchatx/src/frontend/components/tools/PaperMessagePage.vue
+++ b/meshchatx/src/frontend/components/tools/PaperMessagePage.vue
@@ -138,8 +138,8 @@
<h2 class="text-blue-600 dark:text-blue-400">Generated QR Code</h2>
</div>
<div class="glass-card__body flex flex-col items-center p-4 sm:p-6">
- <div class="p-4 bg-white rounded-2xl shadow-inner border border-gray-100 mb-6">
- <div class="size-48 sm:size-64 flex items-center justify-center overflow-hidden">
+ <div class="p-3 bg-white rounded-2xl shadow-inner border border-gray-100 mb-6">
+ <div class="size-40 sm:size-48 flex items-center justify-center overflow-hidden">
<canvas ref="qrcode"></canvas>
</div>
</div>
@@ -181,11 +181,20 @@
</button>
<button
type="button"
- class="flex-1 flex items-center justify-center gap-2 py-2.5 px-4 bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 text-gray-700 dark:text-zinc-200 rounded-xl font-bold transition-all active:scale-[0.98] text-sm"
- @click="downloadQRCode"
+ class="flex-1 flex items-center justify-center gap-2 py-2.5 px-4 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl font-bold shadow-lg shadow-emerald-500/20 transition-all active:scale-[0.98] text-sm"
+ :disabled="isSending"
+ @click="sendPaperMessage"
>
- <MaterialDesignIcon icon-name="download" class="size-4" />
- Save
+ <template v-if="isSending">
+ <div
+ class="size-4 border-2 border-white/20 border-t-white rounded-full animate-spin"
+ ></div>
+ Sending...
+ </template>
+ <template v-else>
+ <MaterialDesignIcon icon-name="send" class="size-4" />
+ Send
+ </template>
</button>
</div>
</div>
@@ -227,6 +236,7 @@ export default {
isGenerating: false,
generatedUri: null,
ingestUri: "",
+ isSending: false,
};
},
computed: {
@@ -284,7 +294,7 @@ export default {
try {
await QRCode.toCanvas(this.$refs.qrcode, this.generatedUri, {
- width: 320,
+ width: 256,
margin: 2,
color: {
dark: "#000000",
@@ -341,6 +351,54 @@ export default {
link.click();
}
},
+ async sendPaperMessage() {
+ const canvas = this.$refs.qrcode;
+ if (!canvas || !this.destinationHash || !this.generatedUri) return;
+
+ try {
+ this.isSending = true;
+
+ // get data url from canvas (format: data:image/png;base64,iVBORw0KG...)
+ const dataUrl = canvas.toDataURL("image/png");
+
+ // extract base64 data by removing the prefix
+ const imageBytes = dataUrl.split(",")[1];
+
+ // build lxmf message
+ const lxmf_message = {
+ destination_hash: this.destinationHash,
+ content: this.generatedUri,
+ fields: {
+ image: {
+ image_type: "png",
+ image_bytes: imageBytes,
+ name: "qrcode.png",
+ },
+ },
+ };
+
+ // send message
+ const response = await window.axios.post(`/api/v1/lxmf-messages/send`, {
+ delivery_method: "opportunistic",
+ lxmf_message: lxmf_message,
+ });
+
+ if (response.data.status === "success") {
+ ToastUtils.success("Paper message sent successfully");
+ this.generatedUri = null;
+ this.destinationHash = "";
+ this.content = "";
+ this.title = "";
+ } else {
+ ToastUtils.error(response.data.message || "Failed to send paper message");
+ }
+ } catch (err) {
+ console.error("Failed to send paper message:", err);
+ ToastUtils.error("Failed to send paper message");
+ } finally {
+ this.isSending = false;
+ }
+ },
printQRCode() {
const canvas = this.$refs.qrcode;
if (!canvas) return;

diff --git a/meshchatx/src/frontend/components/tools/RNPathPage.vue b/meshchatx/src/frontend/components/tools/RNPathPage.vue
new file mode 100644
index 00000000..588a6999
--- /dev/null
+++ b/meshchatx/src/frontend/components/tools/RNPathPage.vue
@@ -0,0 +1,311 @@
+<template>
+ <div class="flex flex-col flex-1 h-full overflow-hidden bg-slate-50 dark:bg-zinc-950">
+ <!-- header -->
+ <div
+ class="flex items-center px-4 py-4 bg-white dark:bg-zinc-900 border-b border-gray-200 dark:border-zinc-800 shadow-sm"
+ >
+ <div class="flex items-center gap-3">
+ <div class="p-2 bg-indigo-100 dark:bg-indigo-900/30 rounded-lg">
+ <MaterialDesignIcon icon-name="route" class="size-6 text-indigo-600 dark:text-indigo-400" />
+ </div>
+ <div>
+ <h1 class="text-xl font-bold text-gray-900 dark:text-white">RNPath</h1>
+ <p class="text-sm text-gray-500 dark:text-gray-400">Reticulum Path Management Utility</p>
+ </div>
+ </div>
+
+ <div class="ml-auto flex items-center gap-2">
+ <button
+ class="p-2 text-gray-500 hover:text-indigo-500 dark:text-gray-400 dark:hover:text-indigo-400 transition-colors"
+ title="Refresh"
+ @click="refreshAll"
+ >
+ <MaterialDesignIcon icon-name="refresh" class="size-6" :class="{ 'animate-spin': isLoading }" />
+ </button>
+ </div>
+ </div>
+
+ <div class="flex-1 overflow-y-auto p-4 md:p-6 space-y-6">
+ <!-- tabs -->
+ <div class="flex border-b border-gray-200 dark:border-zinc-800">
+ <button
+ v-for="t in ['table', 'rates', 'actions']"
+ :key="t"
+ class="px-6 py-3 text-sm font-semibold transition-colors border-b-2 -mb-px"
+ :class="[
+ tab === t
+ ? 'text-indigo-600 border-indigo-500 dark:text-indigo-400 dark:border-indigo-400'
+ : 'text-gray-500 border-transparent hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200',
+ ]"
+ @click="tab = t"
+ >
+ {{ t.charAt(0).toUpperCase() + t.slice(1) }}
+ </button>
+ </div>
+
+ <!-- path table -->
+ <div v-if="tab === 'table'" class="space-y-4">
+ <div v-if="pathTable.length === 0" class="glass-card p-12 text-center text-gray-500">
+ No paths currently known.
+ </div>
+ <div v-else class="grid gap-4">
+ <div
+ v-for="path in pathTable"
+ :key="path.hash"
+ class="glass-card p-4 flex flex-col sm:flex-row sm:items-center justify-between gap-4"
+ >
+ <div class="min-w-0">
+ <div class="flex items-center gap-2 mb-1">
+ <span class="font-mono text-sm font-bold text-indigo-600 dark:text-indigo-400 truncate">
+ {{ path.hash }}
+ </span>
+ <span
+ class="px-2 py-0.5 text-[10px] font-bold bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300 rounded uppercase tracking-wider"
+ >
+ {{ path.hops }} {{ path.hops === 1 ? "hop" : "hops" }}
+ </span>
+ </div>
+ <div class="text-xs text-gray-500 dark:text-gray-400 font-mono truncate">
+ via {{ path.via }} on {{ path.interface }}
+ </div>
+ <div class="text-[10px] text-gray-400 mt-1">Expires: {{ formatDate(path.expires) }}</div>
+ </div>
+ <button
+ class="px-3 py-1.5 text-xs font-semibold text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20 rounded-lg transition-colors border border-red-200 dark:border-red-900/30"
+ @click="dropPath(path.hash)"
+ >
+ Drop Path
+ </button>
+ </div>
+ </div>
+ </div>
+
+ <!-- announce rates -->
+ <div v-if="tab === 'rates'" class="space-y-4">
+ <div v-if="rateTable.length === 0" class="glass-card p-12 text-center text-gray-500">
+ No announce rate data available.
+ </div>
+ <div v-else class="grid gap-4">
+ <div v-for="rate in rateTable" :key="rate.hash" class="glass-card p-4">
+ <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-3">
+ <span class="font-mono text-sm font-bold text-indigo-600 dark:text-indigo-400 truncate">
+ {{ rate.hash }}
+ </span>
+ <span
+ v-if="rate.blocked_until > Date.now() / 1000"
+ class="px-2 py-0.5 text-[10px] font-bold bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded"
+ >
+ RATE LIMITED
+ </span>
+ </div>
+ <div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
+ <div>
+ <div class="text-[10px] uppercase text-gray-500">Last Heard</div>
+ <div class="text-xs font-medium">{{ formatTimeAgo(rate.last) }}</div>
+ </div>
+ <div>
+ <div class="text-[10px] uppercase text-gray-500">Announces</div>
+ <div class="text-xs font-medium">{{ rate.timestamps.length }}</div>
+ </div>
+ <div>
+ <div class="text-[10px] uppercase text-gray-500">Violations</div>
+ <div
+ class="text-xs font-medium"
+ :class="rate.rate_violations > 0 ? 'text-red-500' : ''"
+ >
+ {{ rate.rate_violations }}
+ </div>
+ </div>
+ <div>
+ <div class="text-[10px] uppercase text-gray-500">Rate</div>
+ <div class="text-xs font-medium">{{ calculateRate(rate) }} / hr</div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- manual actions -->
+ <div v-if="tab === 'actions'" class="max-w-2xl mx-auto space-y-6">
+ <!-- request path -->
+ <section class="glass-card p-6 space-y-4">
+ <h2 class="text-lg font-bold">Request Path</h2>
+ <p class="text-sm text-gray-500">Broadcast a path request for a destination hash.</p>
+ <div class="flex gap-2">
+ <input
+ v-model="requestHash"
+ type="text"
+ placeholder="Destination Hash (32 hex chars)"
+ class="input-field flex-1 font-mono"
+ />
+ <button
+ class="px-4 py-2 bg-indigo-600 text-white rounded-xl font-semibold hover:bg-indigo-500 transition shadow-lg shadow-indigo-500/20 active:scale-95 disabled:opacity-50"
+ :disabled="requestHash.length !== 32"
+ @click="requestPath"
+ >
+ Request
+ </button>
+ </div>
+ </section>
+
+ <!-- drop all via -->
+ <section class="glass-card p-6 space-y-4">
+ <h2 class="text-lg font-bold">Drop All Via</h2>
+ <p class="text-sm text-gray-500">
+ Remove all known paths routed through a specific transport instance.
+ </p>
+ <div class="flex gap-2">
+ <input
+ v-model="dropViaHash"
+ type="text"
+ placeholder="Transport Instance Hash"
+ class="input-field flex-1 font-mono"
+ />
+ <button
+ class="px-4 py-2 bg-red-600 text-white rounded-xl font-semibold hover:bg-red-500 transition shadow-lg shadow-red-500/20 active:scale-95 disabled:opacity-50"
+ :disabled="dropViaHash.length !== 32"
+ @click="dropAllVia"
+ >
+ Drop All
+ </button>
+ </div>
+ </section>
+
+ <!-- drop queues -->
+ <section class="glass-card p-6 space-y-4">
+ <h2 class="text-lg font-bold">Drop Announce Queues</h2>
+ <p class="text-sm text-gray-500">
+ Clear all outbound announce packets currently queued on all interfaces.
+ </p>
+ <button
+ class="w-full px-4 py-3 bg-zinc-800 text-white rounded-xl font-semibold hover:bg-zinc-700 transition active:scale-95"
+ @click="dropAnnounceQueues"
+ >
+ Purge All Queues
+ </button>
+ </section>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+import MaterialDesignIcon from "../MaterialDesignIcon.vue";
+import ToastUtils from "../../js/ToastUtils";
+import DialogUtils from "../../js/DialogUtils";
+import Utils from "../../js/Utils";
+
+export default {
+ name: "RNPathPage",
+ components: {
+ MaterialDesignIcon,
+ },
+ data() {
+ return {
+ tab: "table",
+ isLoading: false,
+ pathTable: [],
+ rateTable: [],
+ requestHash: "",
+ dropViaHash: "",
+ };
+ },
+ mounted() {
+ this.refreshAll();
+ },
+ methods: {
+ async refreshAll() {
+ this.isLoading = true;
+ try {
+ const [pathRes, rateRes] = await Promise.all([
+ window.axios.get("/api/v1/rnpath/table"),
+ window.axios.get("/api/v1/rnpath/rates"),
+ ]);
+ this.pathTable = pathRes.data.table;
+ this.rateTable = rateRes.data.rates;
+ } catch (e) {
+ console.error(e);
+ ToastUtils.error("Failed to fetch path data");
+ } finally {
+ this.isLoading = false;
+ }
+ },
+ async dropPath(hash) {
+ if (!(await DialogUtils.confirm(`Are you sure you want to drop the path to ${hash}?`))) {
+ return;
+ }
+ try {
+ const res = await window.axios.post("/api/v1/rnpath/drop", { destination_hash: hash });
+ if (res.data.success) {
+ ToastUtils.success("Path dropped");
+ this.refreshAll();
+ } else {
+ ToastUtils.error("Could not drop path");
+ }
+ } catch {
+ ToastUtils.error("Error dropping path");
+ }
+ },
+ async requestPath() {
+ try {
+ await window.axios.post("/api/v1/rnpath/request", { destination_hash: this.requestHash });
+ ToastUtils.success(`Path requested for ${this.requestHash.substring(0, 8)}...`);
+ this.requestHash = "";
+ // Path requests take time, don't refresh immediately
+ } catch {
+ ToastUtils.error("Failed to request path");
+ }
+ },
+ async dropAllVia() {
+ if (!(await DialogUtils.confirm(`Drop ALL paths via ${this.dropViaHash}?`))) {
+ return;
+ }
+ try {
+ const res = await window.axios.post("/api/v1/rnpath/drop-via", {
+ transport_instance_hash: this.dropViaHash,
+ });
+ if (res.data.success) {
+ ToastUtils.success("Paths dropped");
+ this.dropViaHash = "";
+ this.refreshAll();
+ }
+ } catch {
+ ToastUtils.error("Failed to drop paths");
+ }
+ },
+ async dropAnnounceQueues() {
+ if (!(await DialogUtils.confirm("Purge all announce queues? This cannot be undone."))) {
+ return;
+ }
+ try {
+ await window.axios.post("/api/v1/rnpath/drop-queues");
+ ToastUtils.success("Announce queues purged");
+ } catch {
+ ToastUtils.error("Failed to purge queues");
+ }
+ },
+ calculateRate(rate) {
+ if (rate.timestamps.length === 0) return 0;
+ const startTs = rate.timestamps[0];
+ const span = Math.max(Date.now() / 1000 - startTs, 3600.0);
+ const spanHours = span / 3600.0;
+ return (rate.timestamps.length / spanHours).toFixed(2);
+ },
+ formatDate(ts) {
+ return new Date(ts * 1000).toLocaleString();
+ },
+ formatTimeAgo(ts) {
+ return Utils.formatSecondsAgo(Math.floor(Date.now() / 1000 - ts));
+ },
+ },
+};
+</script>
+
+<style scoped>
+.glass-card {
+ @apply bg-white/90 dark:bg-zinc-900/80 backdrop-blur border border-gray-200 dark:border-zinc-800 rounded-2xl shadow-lg;
+}
+.input-field {
+ @apply bg-gray-50/90 dark:bg-zinc-800/80 border border-gray-200 dark:border-zinc-700 text-sm rounded-xl focus:ring-2 focus:ring-indigo-400 focus:border-indigo-400 dark:focus:ring-indigo-500 dark:focus:border-indigo-500 block w-full p-3 text-gray-900 dark:text-gray-100 transition;
+}
+</style>

diff --git a/meshchatx/src/frontend/components/tools/ToolsPage.vue b/meshchatx/src/frontend/components/tools/ToolsPage.vue
index bb8a85dc..389ceae1 100644
--- a/meshchatx/src/frontend/components/tools/ToolsPage.vue
+++ b/meshchatx/src/frontend/components/tools/ToolsPage.vue
@@ -70,6 +70,21 @@
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
</RouterLink>
+ <RouterLink :to="{ name: 'rnpath' }" class="tool-card glass-card">
+ <div
+ class="tool-card__icon bg-indigo-50 text-indigo-500 dark:bg-indigo-900/30 dark:text-indigo-200"
+ >
+ <MaterialDesignIcon icon-name="route" class="w-6 h-6" />
+ </div>
+ <div class="flex-1">
+ <div class="tool-card__title">{{ $t("tools.rnpath.title") }}</div>
+ <div class="tool-card__description">
+ {{ $t("tools.rnpath.description") }}
+ </div>
+ </div>
+ <MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
+ </RouterLink>
+
<RouterLink :to="{ name: 'translator' }" class="tool-card glass-card">
<div
class="tool-card__icon bg-indigo-50 text-indigo-500 dark:bg-indigo-900/30 dark:text-indigo-200"


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────